xtr-webhook 4.0.2__py3-none-any.whl

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 (56) hide show
  1. xtr_webhook/.agents/skills/xtr-webhook/SKILL.md +350 -0
  2. xtr_webhook/.agents/skills/xtr-webhook/references/signature-formats.md +189 -0
  3. xtr_webhook/__init__.py +10 -0
  4. xtr_webhook/_endpoint_paths.py +22 -0
  5. xtr_webhook/_standard_webhooks_signature.py +76 -0
  6. xtr_webhook/bundle/__init__.py +15 -0
  7. xtr_webhook/bundle/_routed_parsers.py +70 -0
  8. xtr_webhook/bundle/no_private_network_config.py +59 -0
  9. xtr_webhook/bundle/route_config.py +34 -0
  10. xtr_webhook/bundle/webhook_bundle.py +262 -0
  11. xtr_webhook/bundle/webhook_config.py +80 -0
  12. xtr_webhook/client/__init__.py +23 -0
  13. xtr_webhook/client/abstract_request_parser.py +248 -0
  14. xtr_webhook/client/request_parser.py +272 -0
  15. xtr_webhook/client/request_parser_interface.py +90 -0
  16. xtr_webhook/controller/__init__.py +12 -0
  17. xtr_webhook/controller/_built_parsers.py +65 -0
  18. xtr_webhook/controller/webhook_controller.py +140 -0
  19. xtr_webhook/exception/__init__.py +25 -0
  20. xtr_webhook/exception/delivery_error.py +38 -0
  21. xtr_webhook/exception/invalid_argument_error.py +18 -0
  22. xtr_webhook/exception/logic_error.py +15 -0
  23. xtr_webhook/exception/reject_webhook_error.py +52 -0
  24. xtr_webhook/exception/webhook_error.py +9 -0
  25. xtr_webhook/exception/webhook_runtime_error.py +16 -0
  26. xtr_webhook/messenger/__init__.py +14 -0
  27. xtr_webhook/messenger/send_webhook_handler.py +49 -0
  28. xtr_webhook/messenger/send_webhook_message.py +36 -0
  29. xtr_webhook/py.typed +0 -0
  30. xtr_webhook/recipe/__init__.py +18 -0
  31. xtr_webhook/recipe/files/config/routes/webhook.py.tmpl +25 -0
  32. xtr_webhook/recipe/files/config/webhook.py.tmpl +33 -0
  33. xtr_webhook/recipe/manifest.toml +35 -0
  34. xtr_webhook/routing/__init__.py +11 -0
  35. xtr_webhook/routing/_endpoints.py +38 -0
  36. xtr_webhook/routing/webhook_route_loader.py +31 -0
  37. xtr_webhook/server/__init__.py +39 -0
  38. xtr_webhook/server/header_signature_configurator.py +128 -0
  39. xtr_webhook/server/headers_configurator.py +92 -0
  40. xtr_webhook/server/json_body_configurator.py +80 -0
  41. xtr_webhook/server/json_payload_serializer.py +45 -0
  42. xtr_webhook/server/outgoing_request.py +35 -0
  43. xtr_webhook/server/payload_serializer_interface.py +34 -0
  44. xtr_webhook/server/request_configurator_interface.py +39 -0
  45. xtr_webhook/server/signature_format.py +65 -0
  46. xtr_webhook/server/transport.py +106 -0
  47. xtr_webhook/server/transport_interface.py +40 -0
  48. xtr_webhook/subscriber.py +41 -0
  49. xtr_webhook/testing/__init__.py +18 -0
  50. xtr_webhook/testing/_parser_cases.py +141 -0
  51. xtr_webhook/testing/parser_case.py +37 -0
  52. xtr_webhook-4.0.2.dist-info/METADATA +704 -0
  53. xtr_webhook-4.0.2.dist-info/RECORD +56 -0
  54. xtr_webhook-4.0.2.dist-info/WHEEL +4 -0
  55. xtr_webhook-4.0.2.dist-info/entry_points.txt +6 -0
  56. xtr_webhook-4.0.2.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,704 @@
1
+ Metadata-Version: 2.4
2
+ Name: xtr-webhook
3
+ Version: 4.0.2
4
+ Summary: Receive webhooks — verified, replay-bounded, turned into events — and send them signed, in the legacy or the Standard Webhooks format.
5
+ Keywords: webhook,standard-webhooks,signature,hmac,remote-event,asyncio
6
+ Author: Xterr
7
+ Author-email: Xterr <me@xterr.dev>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Framework :: AsyncIO
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Typing :: Typed
18
+ Requires-Dist: typing-extensions>=4.4
19
+ Requires-Dist: msgspec>=0.18
20
+ Requires-Dist: xtr-clock>=4.0,<5
21
+ Requires-Dist: xtr-http-client-contracts>=4.0,<5
22
+ Requires-Dist: xtr-http-foundation>=4.0,<5
23
+ Requires-Dist: xtr-http-message-contracts>=4.0,<5
24
+ Requires-Dist: xtr-messenger>=4.0,<5
25
+ Requires-Dist: xtr-remote-event>=4.0,<5
26
+ Requires-Dist: xtr-service-contracts>=4.0,<5
27
+ Requires-Dist: xtr-http-kernel>=4.0,<5
28
+ Requires-Dist: fastapi>=0.121.3
29
+ Requires-Dist: xtr-http-client>=4.0,<5 ; extra == 'client'
30
+ Requires-Dist: xtr-dependency-injection>=4.0,<5 ; extra == 'di'
31
+ Requires-Dist: xtr-http-message>=4.0,<5 ; extra == 'testing'
32
+ Requires-Python: >=3.11
33
+ Provides-Extra: client
34
+ Provides-Extra: di
35
+ Provides-Extra: testing
36
+ Description-Content-Type: text/markdown
37
+
38
+ <div align="center">
39
+
40
+ # xtr-webhook
41
+
42
+ **Receive webhooks — verified, replay-bounded, turned into events — and send them signed, in the legacy or the Standard Webhooks format.**
43
+
44
+ <img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
45
+ <img alt="asyncio" src="https://img.shields.io/badge/asyncio-native-1f6feb">
46
+ <img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
47
+ <img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
48
+
49
+ </div>
50
+
51
+ ---
52
+
53
+ ## Why?
54
+
55
+ A webhook is an HTTP request from someone who is not a user, so the usual answers do not
56
+ apply: there is no session to trust, the body is the only thing signed, and a request captured
57
+ once can be sent again tomorrow. Handling one well means checking it against the right
58
+ secret, refusing anything outside a window, and getting off the request as fast as possible.
59
+
60
+ Sending one is the same problem from the other side: sign the exact bytes, say when they were
61
+ signed, and retry what the receiver refused.
62
+
63
+ This package does both ends, and turns what arrives into an
64
+ [xtr-remote-event](../xtr-remote-event) event, so the code that handles it never sees HTTP.
65
+
66
+ ## Install
67
+
68
+ ```sh
69
+ uv add xtr-webhook # signatures, parsers, the endpoint, the events a webhook becomes
70
+ uv add "xtr-webhook[client]" # + sending signed webhooks, through xtr-http-client
71
+ uv add "xtr-webhook[di]" # + the bundle
72
+ ```
73
+
74
+ ## Quick start
75
+
76
+ Receiving, with nothing but this package and a bus that handles in process. A controller is
77
+ given the parser for each type, the secret each is checked against, and where to hand on what it
78
+ read:
79
+
80
+ ```python
81
+ import asyncio
82
+ import hmac
83
+
84
+ from xtr_http_message import Response, ServerRequest
85
+ from xtr_messenger import HandlersLocator, MessageBusConfig, MessageBusFactory, TransportConfig
86
+ from xtr_remote_event.messenger import ConsumeRemoteEventMessage
87
+ from xtr_webhook.client import RequestParser
88
+ from xtr_webhook.controller import WebhookController
89
+
90
+ SECRET = "the-secret"
91
+ BODY = b'{"total": 4200}'
92
+
93
+
94
+ async def consume(message: ConsumeRemoteEventMessage) -> None:
95
+ event = message.event
96
+ print(f"consumed {event.name} {event.id} {event.payload}")
97
+
98
+
99
+ async def main() -> None:
100
+ handlers = HandlersLocator()
101
+ _ = handlers.register(ConsumeRemoteEventMessage, consume)
102
+ bus = MessageBusFactory(
103
+ MessageBusConfig(
104
+ transports={"sync": TransportConfig("sync://")},
105
+ routing={ConsumeRemoteEventMessage: "sync"},
106
+ ),
107
+ handlers=handlers,
108
+ ).bus()
109
+
110
+ controller = WebhookController({"acme": RequestParser()}, {"acme": SECRET}, bus)
111
+
112
+ signed = hmac.new(SECRET.encode(), b"order.paid" + b"evt_1" + BODY, "sha256").hexdigest()
113
+ arrived = ServerRequest(
114
+ "POST",
115
+ "/webhook/acme",
116
+ {
117
+ "Content-Type": "application/json",
118
+ "Webhook-Event": "order.paid",
119
+ "Webhook-Id": "evt_1",
120
+ "Webhook-Signature": f"sha256={signed}",
121
+ },
122
+ BODY,
123
+ )
124
+
125
+ answer = await controller.index_action("acme", arrived, Response())
126
+ print(f"answered {answer.get_status_code()}")
127
+
128
+
129
+ asyncio.run(main())
130
+ ```
131
+
132
+ ```
133
+ consumed order.paid evt_1 {'total': 4200}
134
+ answered 202
135
+ ```
136
+
137
+ In an application the endpoint and the controller are the bundle's — see
138
+ [Use in an application](#use-in-an-application).
139
+
140
+ ## Receiving
141
+
142
+ `setup(app, kernel)` adds the endpoint — no `include_router` of your own:
143
+
144
+ ```python
145
+ from fastapi import FastAPI
146
+ from xtr_dependency_injection import Kernel
147
+ from xtr_http_kernel import setup
148
+
149
+ app = FastAPI()
150
+ setup(app, Kernel("app"))
151
+ ```
152
+
153
+ That serves `POST /webhook/{type}`, contributed as the route resource `webhook`. A routes file in
154
+ `<app>/config/routes/` — `routes.import_("webhook").prefix("/hooks")` — moves it to
155
+ `/hooks/webhook/{type}`, for an application keeping everything a stranger may call under one
156
+ path. Whichever it is, build the
157
+ URL you hand a sender from the route's name, `"webhook"` — a sender that was told a URL keeps
158
+ sending to it long after the deploy that changed the path.
159
+
160
+ One endpoint serves every sender. The `type` segment tells them apart: it picks the parser that
161
+ reads the webhook and the secret it is checked against, both configuration, so a new sender
162
+ needs no new route.
163
+
164
+ ```python
165
+ # app/config/webhook.py
166
+ from xtr_dependency_injection import configure, env
167
+
168
+ from xtr_webhook.bundle import RouteConfig, WebhookConfig
169
+
170
+
171
+ @configure
172
+ def webhook() -> WebhookConfig:
173
+ return WebhookConfig(
174
+ routing={
175
+ "acme": RouteConfig(secret=env("WEBHOOK_SECRET")),
176
+ "stripe": RouteConfig(parser=StripeRequestParser, secret=env("STRIPE_SECRET")),
177
+ },
178
+ )
179
+ ```
180
+
181
+ Pass a secret through `env(...)`, never as a literal: a placeholder is read only when the
182
+ endpoint is built, and no container report prints it.
183
+
184
+ Routing keys are kept verbatim, dashes included. A type nothing is routed to is answered `404`
185
+ with `No webhook parser found for the type given in the URL.` — from the sender's side that URL
186
+ does not exist.
187
+
188
+ ### What the endpoint answers
189
+
190
+ | Answer | When |
191
+ |---|---|
192
+ | `202` | the webhook was read, and every event it carried is on the bus |
193
+ | `404` | nothing is routed to that type |
194
+ | `406` | the parser refused it: a signature that does not match, a timestamp outside the window, a body that will not decode, a request the matcher does not claim |
195
+ | whatever the refusal carries | a parser raising `RejectWebhookError` with its own status and headers |
196
+ | `5xx` | something behind the endpoint failed — a bus with nothing to handle the event above all |
197
+
198
+ The answer goes out before the events are handled, which is the point: a sender waiting on a
199
+ handler is a sender that times out and sends the webhook again. The refusal body is plain text
200
+ naming the check that refused, never the values that failed it.
201
+
202
+ A `5xx` is deliberate rather than tidied away. The sender then sends the webhook again, which is
203
+ the only outcome that does not lose what it carried.
204
+
205
+ ### Writing a parser
206
+
207
+ One parser speaks for one sender. Subclass `AbstractRequestParser` and fill in the two things
208
+ that differ per sender — which requests are that sender's, and how to check and read one:
209
+
210
+ ```python
211
+ from xtr_http_foundation import (
212
+ ChainRequestMatcher,
213
+ IsJsonRequestMatcher,
214
+ MethodRequestMatcher,
215
+ RequestMatcherInterface,
216
+ )
217
+ from xtr_http_message_contracts import ServerRequestInterface
218
+ from xtr_remote_event import RemoteEvent
219
+
220
+ from xtr_webhook.client import AbstractRequestParser
221
+ from xtr_webhook.exception import RejectWebhookError
222
+
223
+
224
+ class AcmeRequestParser(AbstractRequestParser):
225
+ def get_request_matcher(self) -> RequestMatcherInterface:
226
+ return ChainRequestMatcher([MethodRequestMatcher("POST"), IsJsonRequestMatcher()])
227
+
228
+ async def do_parse(self, request: ServerRequestInterface, secret: str) -> RemoteEvent:
229
+ if request.get_header_line("X-Acme-Token") != secret:
230
+ raise RejectWebhookError(406, "Signature is wrong.")
231
+ payload = await self.read_json(request)
232
+ return RemoteEvent("acme", str(payload["id"]), payload)
233
+ ```
234
+
235
+ The base class does what every parser would otherwise write again, and get wrong in the same
236
+ way: the matcher runs before anything is read out of the request, a body that will not decode
237
+ comes back as a `406` rather than as a `500`, and the two answers are the ones the sender's side
238
+ waits for.
239
+
240
+ - `await self.read_body(request)` returns every byte of the body from its first one, rewinding
241
+ the stream where it can — a signature covers exactly the bytes that arrived. Reading a body is
242
+ awaited, so a request whose body is still arriving is read as it comes.
243
+ - `await self.read_json(request)` decodes the body as a JSON object. An array or a bare value is
244
+ refused; bytes that are not JSON at all raise, and `parse()` turns that into the same `406`.
245
+ - `do_parse` returns one event, a sequence of them, or `None` for a webhook to accept and
246
+ ignore — a keep-alive, an event kind this parser does not speak for. `None` and an empty
247
+ sequence are both answered as unparsable rather than accepted and dropped.
248
+ - Override `create_successful_response` — an `async def`, so it can write a body — for a sender
249
+ that wants something of its own echoed back. `create_rejected_response` is the same for a
250
+ refusal. The response is handed in and given back modified, because an HTTP message is
251
+ immutable and the endpoint owns the response.
252
+ - Compare secrets with `hmac.compare_digest`, never `==`.
253
+
254
+ The matchers come from [xtr-http-foundation](../xtr-http-foundation), so a webhook endpoint
255
+ says which requests are its sender's the same way a firewall or a rate limiter does. They are
256
+ small and compose: `MethodRequestMatcher(["POST", "PUT"])`, `IsJsonRequestMatcher()`,
257
+ `ChainRequestMatcher([...])` (all must match), and the rest — the path, the host, the port, the
258
+ scheme, a header, a query parameter, the address it came from. `matches()` is awaited, because
259
+ a rule may be about the body.
260
+
261
+ `IsJsonRequestMatcher` reads the body, not the `Content-Type` header: a sender that announces
262
+ nothing still sent JSON, and one that announces `application/json` and sent something else did
263
+ not — so that request is refused by the matcher, with `Request does not match.`, before the
264
+ parser is handed anything.
265
+
266
+ ### Signature formats
267
+
268
+ `RequestParser` is this library's own parser, the mirror of its sender, and reads either of two
269
+ signature schemes. `SignatureFormat` picks which a request must carry:
270
+
271
+ | Format | Signature header | Signed over | Event name in | Replay bounded |
272
+ |---|---|---|---|---|
273
+ | `SignatureFormat.LEGACY` | `sha256=<hex>` | event name + id + body | the `Webhook-Event` header | no |
274
+ | `SignatureFormat.STANDARD` | `v1,<base64>` | `<id>.<timestamp>.` + body | the payload's `type` key | yes |
275
+ | `SignatureFormat.TRANSITIONAL` | both, space separated | both of the above | both | for the Standard Webhooks entry |
276
+
277
+ The members *are* their strings, so a configuration carries `"standard"` and is understood with
278
+ no conversion step. Anything else is refused by `SignatureFormat.parse` with
279
+ `InvalidArgumentError`.
280
+
281
+ Both ends must have made the same choice, which is why the sender's configurators and the
282
+ receiver's parser each take it. `TRANSITIONAL` exists so they need not change on the same day: a
283
+ receiver accepts whichever scheme it already understands.
284
+
285
+ Under `STANDARD` the event name travels in the payload because that scheme's signature covers no
286
+ header at all — a name sent as one is a value the receiver cannot trust. A payload with no
287
+ `type` string names no event and is refused.
288
+
289
+ ### Rotating a secret
290
+
291
+ The signature header may carry several whitespace-separated entries, one per secret in play, and
292
+ any one that verifies is enough. So a rotation is: the sender starts sending both signatures, the
293
+ receiver's secret is changed, the sender drops the old one. Each expectation is computed once
294
+ rather than per entry, and every comparison is `hmac.compare_digest`, so the time a refusal takes
295
+ says nothing about how much of it was right.
296
+
297
+ ### The replay window
298
+
299
+ A delivery captured once can be sent again tomorrow, so a signed timestamp is held to a window
300
+ around now — `timestamp_tolerance`, 300 seconds by default. Zero or less turns the bound off, for
301
+ when clocks that cannot be kept close enough matter more than a replayed capture.
302
+
303
+ Plainly: **a `LEGACY` signature carries no timestamp, so the window protects only `STANDARD` and
304
+ `TRANSITIONAL`.** Under `LEGACY` the timestamp header is still sent, but nothing signs it, so
305
+ refusing on it would mean trusting a value an attacker sets. The check runs only after a Standard
306
+ Webhooks entry verified, which is when the timestamp is known to be the sender's. A timestamp
307
+ that does not read as an integer is refused too — a value that cannot be bounded is not accepted
308
+ on faith.
309
+
310
+ A request carrying both schemes is tried Standard-first, so it is replay-bounded whatever order
311
+ its entries arrive in.
312
+
313
+ ### An empty secret
314
+
315
+ `RequestParser` raises `InvalidArgumentError` rather than refusing the request: an empty secret
316
+ is this side's configuration, not the sender's doing, so it surfaces as a `5xx`. A type with no
317
+ `secret` in its `RouteConfig` is checked against `""`, so set one in production — a parser of
318
+ your own that verifies nothing would otherwise accept every sender.
319
+
320
+ ## Sending
321
+
322
+ Install `xtr-webhook[client]`. A webhook goes out as a message, so the delivery is not in the
323
+ request that caused it, and a refusal is retried by the bus:
324
+
325
+ ```python
326
+ from xtr_remote_event import RemoteEvent
327
+
328
+ from xtr_webhook.messenger import SendWebhookMessage
329
+ from xtr_webhook.subscriber import Subscriber
330
+
331
+ _ = await bus.dispatch(
332
+ SendWebhookMessage(
333
+ Subscriber("https://example.com/hook", secret),
334
+ RemoteEvent("order.paid", "evt_1", {"total": 4200}),
335
+ )
336
+ )
337
+ ```
338
+
339
+ A `Subscriber` is where the webhook goes and what signs it; the pair travels together because
340
+ neither is useful alone. Its `secret` is kept out of the `repr`, and an empty one is refused with
341
+ `InvalidArgumentError` — it would still sign, over the empty key, and send perfectly formed
342
+ webhooks no receiver can accept.
343
+
344
+ The message carries the subscriber whole rather than an id, because this library stores no
345
+ subscribers: the application knows where a webhook goes and says so once, here.
346
+
347
+ ### What goes out
348
+
349
+ A webhook is built by a chain of configurators, each writing one part of the same
350
+ `OutgoingRequest`: `HeadersConfigurator` the headers naming the event, `JsonBodyConfigurator` the
351
+ payload as the body, `HeaderSignatureConfigurator` the signature over what the other two wrote.
352
+ It runs last, because it signs their output. They are separate because receivers disagree about
353
+ all three, so an unusual one means adding or replacing a step rather than the sender.
354
+
355
+ `Transport` runs the chain and posts what it built through an `HttpClientInterface` — the
356
+ contract from [xtr-http-client-contracts](../xtr-http-client-contracts), so whatever stands
357
+ behind it does: the client the bundle is told to use, a scoped one, a client wrapped in the guard
358
+ below, or a `MockHttpClient` in a test. It asks the client for the request alone, with no
359
+ redirect followed; everything else — how long a delivery may take, above all — is the client's
360
+ configuration.
361
+
362
+ Under `LEGACY`, `RemoteEvent("order.paid", "evt_1", {"total": 4200})`:
363
+
364
+ ```http
365
+ POST /hook HTTP/1.1
366
+ Content-Type: application/json
367
+ Webhook-Id: evt_1
368
+ Webhook-Timestamp: 1767225600
369
+ Webhook-Event: order.paid
370
+ Webhook-Signature: sha256=0f8c...
371
+
372
+ {"total":4200}
373
+ ```
374
+
375
+ Under `STANDARD` the `Webhook-Event` header is not sent at all and the name is written into the
376
+ payload instead:
377
+
378
+ ```http
379
+ POST /hook HTTP/1.1
380
+ Content-Type: application/json
381
+ Webhook-Id: evt_1
382
+ Webhook-Timestamp: 1767225600
383
+ Webhook-Signature: v1,dGhlLXNpZ25hdHVyZQ==
384
+
385
+ {"total":4200,"type":"order.paid"}
386
+ ```
387
+
388
+ Under `TRANSITIONAL` both headers are sent and `Webhook-Signature` carries both entries, space
389
+ separated.
390
+
391
+ A payload that already says what it is keeps what it says: the application knows its own events
392
+ better than their source name does. The payload is copied before the key is added, so dispatching
393
+ a webhook never edits the dict the caller is still holding.
394
+
395
+ The body is written as compact JSON with nothing reordered — the bytes are the signed message, so
396
+ two runs over the same payload have to produce the same ones. A value JSON has no form for raises
397
+ `TypeError` from the serializer, unwrapped: what it refuses is its own vocabulary.
398
+
399
+ Header names and the legacy digest are all configurable:
400
+
401
+ | Option | Default |
402
+ |---|---|
403
+ | `event_header_name` | `"Webhook-Event"` |
404
+ | `id_header_name` | `"Webhook-Id"` |
405
+ | `timestamp_header_name` | `"Webhook-Timestamp"` |
406
+ | `signature_header_name` | `"Webhook-Signature"` |
407
+ | `signing_algorithm` | `"sha256"` |
408
+ | `signature_format` | `SignatureFormat.LEGACY` |
409
+ | `timestamp_tolerance` | `300` |
410
+
411
+ An algorithm this runtime cannot make is refused where it is configured, not at the first
412
+ delivery. The Standard Webhooks signature is always SHA-256; the scheme fixes it.
413
+
414
+ ### Delivery errors and retries
415
+
416
+ Any answer outside 200-299 raises `DeliveryError`, carrying `.status_code` and `.url`. Nothing is
417
+ caught in the handler: the error is what makes the bus retry the delivery and, once the attempts
418
+ are spent, keep the message instead of dropping it. Swallowing it would turn every refused
419
+ webhook into one silently never sent. How many attempts, and how long between them, is the
420
+ messenger transport's — see [xtr-messenger](../xtr-messenger).
421
+
422
+ A delivery nothing answered — a connection refused, a receiver that never replied in time —
423
+ raises the client's own `TransportError` (`TransportTimeoutError` for time), and is retried the
424
+ same way. How long is too long is set on the client: `default_options.timeout` in the HTTP client
425
+ configuration, or a scoped client of its own that `http_client` names — see
426
+ [Kernel / bundle](#kernel--bundle).
427
+
428
+ A redirect counts as refused. Redirects are never followed, whatever the client was built with —
429
+ the transport asks for none: following one would carry a signed body to an address nobody
430
+ registered, and would hollow out the guard below.
431
+
432
+ ## Private network guard
433
+
434
+ **Opt-in, and off by default.** A subscriber URL is usually data — chosen by whoever registered
435
+ it — so without a guard a webhook is a way to ask this process to fetch an address only this
436
+ process can see: another service on the same network, an admin port, a cloud provider's metadata
437
+ endpoint handing out credentials.
438
+
439
+ The guard is xtr-http-client's `NoPrivateNetworkHttpClient`, a client wrapping another one —
440
+ whichever the transport is given:
441
+
442
+ ```python
443
+ from xtr_http_client import HttpClient, NoPrivateNetworkHttpClient
444
+
445
+ from xtr_webhook.server import (
446
+ HeadersConfigurator,
447
+ HeaderSignatureConfigurator,
448
+ JsonBodyConfigurator,
449
+ JsonPayloadSerializer,
450
+ Transport,
451
+ )
452
+
453
+ transport = Transport(
454
+ NoPrivateNetworkHttpClient(HttpClient.create()),
455
+ HeadersConfigurator(),
456
+ JsonBodyConfigurator(JsonPayloadSerializer()),
457
+ HeaderSignatureConfigurator(),
458
+ )
459
+ ```
460
+
461
+ The check runs on what the host *resolves to*, not on how it is written, so neither a name
462
+ pointing inwards nor an address spelled unusually gets through. One refused address refuses the
463
+ whole delivery. The request is then pinned to the address that was checked, so a name whose
464
+ records change between the check and the connection — a rebinding attack — connects to what was
465
+ vetted or not at all.
466
+
467
+ A refusal is the client's `TransportError`, raised before anything is sent:
468
+ `Host "<host>" is blocked for "<url>".`, or `IP "<ip>" is blocked for "<url>".` for an address
469
+ written out. A retry will not get through either, since nothing about the network changes by
470
+ trying again.
471
+
472
+ With no `subnets` it refuses the networks only this one can reach — loopback, private,
473
+ link-local, carrier-grade NAT, unique-local, multicast, reserved and unspecified alike. Give
474
+ `subnets` to replace that set, and `allow_list` to open one receiver without opening the network
475
+ it sits in. Both take IP networks or single addresses, never hostnames — the check runs on
476
+ resolved addresses, and a name could point anywhere by the time it is used.
477
+
478
+ In the bundle it is `no_private_network`, and it wraps the client `http_client` names — the
479
+ default one when that is unset:
480
+
481
+ ```python
482
+ WebhookConfig(
483
+ no_private_network=NoPrivateNetworkConfig(enabled=True, allow_list=("10.1.2.3",)),
484
+ )
485
+ ```
486
+
487
+ Naming `subnets` or an `allow_list` while `enabled` is `False` is refused rather than silently
488
+ ignored — a configuration that looks like protection and is not. Turning it on without
489
+ xtr-http-client installed is refused too, with `LogicError`, when the container is built: there
490
+ is no client for it to wrap.
491
+
492
+ ## Testing your parsers
493
+
494
+ `xtr_webhook.testing` turns a directory of recorded payloads into tests. Install the helpers
495
+ with the `testing` extra first — they build requests with xtr-http-message:
496
+
497
+ ```sh
498
+ uv add --dev "xtr-webhook[testing]"
499
+ ```
500
+
501
+ Each `*.json` fixture has a sibling `*.py` defining a module-level `EXPECTED`, the event — or list
502
+ of events — it must parse to:
503
+
504
+ ```python
505
+ from pathlib import Path
506
+
507
+ import pytest
508
+
509
+ from xtr_webhook.testing import ParserCase, assert_parses, parser_cases
510
+
511
+
512
+ @pytest.mark.anyio
513
+ @pytest.mark.parametrize(
514
+ "case", parser_cases(Path(__file__).parent / "fixtures"), ids=lambda case: case.id
515
+ )
516
+ async def test_the_parser_reads_every_recorded_payload(case: ParserCase) -> None:
517
+ await assert_parses(AcmeRequestParser(), case, secret="the-secret")
518
+ ```
519
+
520
+ `parser_cases(fixtures_dir, extension="json")` walks the directory recursively and returns a
521
+ `ParserCase(id, payload, expected)` per fixture, sorted by id, so adding a sender's payload to
522
+ the directory adds a test. A fixture with no expectation file beside it raises
523
+ `InvalidArgumentError` naming both paths — a payload nobody wrote an expectation for is a test
524
+ that silently is not run.
525
+
526
+ `assert_parses(parser, case, *, secret="", headers=None, method="POST")` builds the request with
527
+ `Content-Type: application/json` plus whatever `headers` adds, parses it, and asserts the
528
+ events match. Pass the signature headers a real delivery carries through `headers`.
529
+
530
+ Nothing here imports pytest, so a fixture directory is usable from any runner.
531
+
532
+ ## Security notes
533
+
534
+ - **Subscriber secrets travel inside the queued message body.** `SendWebhookMessage` carries the
535
+ subscriber whole, so **the queue storage must be trusted**. The bus itself adds nothing to
536
+ that: its logging middleware records the message type, the transport, the message id, the
537
+ handler name and the stamps — never the body.
538
+ - **Receiving secrets belong in the environment**, passed as `env("...")`. A `RouteConfig`'s
539
+ `secret` is kept out of its `repr`, and a placeholder is resolved only when the endpoint is
540
+ built.
541
+ - **A `LEGACY` signature is not replay-bounded.** Use `STANDARD` where that matters.
542
+ - **An empty secret signs.** Both ends refuse one rather than send or accept webhooks nobody can
543
+ verify.
544
+ - **Refusals say as little as the sender needs.** Name the check that refused, never the values
545
+ that failed it.
546
+ - **Signatures are compared with `hmac.compare_digest`.** Do the same in a parser of your own.
547
+ - **Nothing follows a redirect**, so a signed body cannot be bounced to an address nobody
548
+ registered.
549
+
550
+ ## Use in an application
551
+
552
+ Everything adding this package to an application on
553
+ [xtr-dependency-injection](../xtr-dependency-injection) takes — and, read backwards, what
554
+ removing it undoes.
555
+
556
+ - **Install** — `uv add "xtr-webhook[client]"`; add `di` for the bundle. The endpoint that
557
+ receives comes with the package; `client` is what sends: it brings
558
+ [xtr-http-client](../xtr-http-client) — the client, the guard and the client's bundle.
559
+ Without `client`, no transport and no send handler are registered, dispatching
560
+ `SendWebhookMessage` fails with the bus's no-handler error, and turning `no_private_network`
561
+ on is refused with `LogicError`.
562
+ - **Recipe** — `uv run xtr-recipes recipes:sync` does the *Activate*, *Configure* and
563
+ *Environment* steps below: it lists `WebhookBundle`, writes a starting
564
+ `<app>/config/webhook.py` with one route, `<app>/config/routes/webhook.py` serving the
565
+ endpoint at its default path, and `WEBHOOK_SECRET` (commented out) in `.env`. There is no
566
+ ignore line to write. It prints the steps to call `setup` and to name your own types, neither
567
+ of which a recipe can make for you.
568
+ - **Activate** — `WebhookBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`, imported
569
+ from `xtr_webhook.bundle`.
570
+ - **Brings along** — the remote-event bundle, the messenger bundle through it, and the HTTP
571
+ kernel's bundle, always; the HTTP client bundle, `http_client`, when xtr-http-client is
572
+ installed.
573
+ - **Configure** — needed to receive anything: with no routing the endpoint knows no type and
574
+ answers every webhook `404`. Routing, header names, the signature format, the client webhooks
575
+ are sent through and the guard go in `<app>/config/webhook.py`, a `@configure` function
576
+ returning `WebhookConfig` — see [Kernel / bundle](#kernel--bundle).
577
+ `<app>/config/routes/webhook.py` imports the `webhook` route resource; move it under a prefix
578
+ there. A delivery's timeout is the client's, set in `<app>/config/http_client.py`.
579
+ - **Environment** — `WEBHOOK_SECRET`, the secret the written route verifies against, read when
580
+ the endpoint is built; one per sender in practice.
581
+ - **Ignore** — nothing.
582
+ - **Load** — call `setup(app, kernel)` where the application is built; it adds the endpoint,
583
+ `POST /webhook/{type}`.
584
+ - **Remove** — drop the `BUNDLES` entry, delete `<app>/config/webhook.py` and
585
+ `<app>/config/routes/webhook.py`, then `uv remove xtr-webhook`.
586
+ - **Check** — `debug:bundles` shows `webhook` and `remote_event` as `listed` and `active`, and
587
+ `http_client` with the `client` extra; `debug:config webhook` shows the resolved routing,
588
+ header names and signature format.
589
+
590
+ ## Kernel / bundle
591
+
592
+ ```python
593
+ # app/bundles.py
594
+ from xtr_webhook.bundle import WebhookBundle
595
+
596
+ BUNDLES = {WebhookBundle: {"all": True}}
597
+ ```
598
+
599
+ ```python
600
+ # app/config/webhook.py
601
+ from xtr_dependency_injection import configure, env
602
+
603
+ from xtr_webhook.bundle import NoPrivateNetworkConfig, RouteConfig, WebhookConfig
604
+ from xtr_webhook.server import SignatureFormat
605
+
606
+
607
+ @configure
608
+ def webhook() -> WebhookConfig:
609
+ return WebhookConfig(
610
+ signature_format=SignatureFormat.STANDARD,
611
+ timestamp_tolerance=300,
612
+ http_client="webhook",
613
+ no_private_network=NoPrivateNetworkConfig(enabled=True),
614
+ routing={
615
+ "acme": RouteConfig(secret=env("WEBHOOK_SECRET")),
616
+ "stripe": RouteConfig(parser=StripeRequestParser, secret=env("STRIPE_SECRET")),
617
+ },
618
+ )
619
+ ```
620
+
621
+ `http_client` names a client of the HTTP client configuration; this one gives webhooks a
622
+ timeout of their own. Leave `http_client` unset to send through the default client, whose
623
+ timeout is `default_options.timeout` — see [xtr-http-client](../xtr-http-client#kernel--bundle).
624
+
625
+ ```python
626
+ # app/config/http_client.py
627
+ from xtr_dependency_injection import configure
628
+ from xtr_http_client.bundle import HttpClientConfig, ScopedClientConfig
629
+
630
+
631
+ @configure
632
+ def http_client() -> HttpClientConfig:
633
+ return HttpClientConfig(
634
+ scoped_clients={"webhook": ScopedClientConfig(scope="https?://", timeout=10)},
635
+ )
636
+ ```
637
+
638
+ `WebhookConfig`:
639
+
640
+ | Field | Default | What it sets |
641
+ |---|---|---|
642
+ | `routing` | `{}` | what the endpoint does with each type, by the type in the URL |
643
+ | `signature_format` | `SignatureFormat.LEGACY` | the scheme both ends share; a string is accepted |
644
+ | `timestamp_tolerance` | `300` | seconds either side of now a signed timestamp may be; `0` turns the bound off |
645
+ | `signing_algorithm` | `"sha256"` | the digest the legacy signature is made with |
646
+ | `event_header_name` | `"Webhook-Event"` | where the event's name travels, where the scheme sends one |
647
+ | `id_header_name` | `"Webhook-Id"` | where the event's id travels |
648
+ | `timestamp_header_name` | `"Webhook-Timestamp"` | where the send time travels |
649
+ | `signature_header_name` | `"Webhook-Signature"` | where the signature entries travel |
650
+ | `http_client` | `None` | the qualifier of the `HttpClientInterface` webhooks are sent through — a scoped client's name; `None` means the default client |
651
+ | `no_private_network` | `NoPrivateNetworkConfig()` | whether that client is wrapped in the guard refusing to deliver inwards, and what counts as inwards |
652
+
653
+ `RouteConfig(parser=None, secret="")`: `parser` is the class reading that type's webhooks, built
654
+ by the container with its dependencies on the first webhook for it; `None` means the bundle's
655
+ own `RequestParser`, configured by the fields above. Two routes naming one class share one
656
+ instance.
657
+
658
+ The bundle registers the configurator chain, its own parser under the `webhook` qualifier — so
659
+ `Annotated[RequestParserInterface, Target("webhook")]` resolves to it — every custom parser the
660
+ routing names, the endpoint's route loader, and the controller behind it. The controller gets its parsers as a
661
+ service locator, so it never holds the container and a route's parser is built only when the
662
+ first webhook for it arrives.
663
+
664
+ The sending side is registered only when xtr-http-client is installed: the transport, over the
665
+ client `http_client` names — wrapped in `NoPrivateNetworkHttpClient` when `no_private_network` is
666
+ on — and `SendWebhookHandler` with it. The handler is registered beside the transport it needs,
667
+ so a container never holds a handler it cannot build. The client belongs to the HTTP client
668
+ bundle, which builds it, opens its connections when a delivery first needs one, and closes them
669
+ with the container.
670
+
671
+ The zero-config path routes nothing, signs with the legacy scheme under the default header names,
672
+ and does no I/O at build or boot.
673
+
674
+ ## Errors
675
+
676
+ Everything this library raises derives from `WebhookError`, and carries what went wrong as typed
677
+ attributes rather than forcing a message to be parsed. None of them repeats a secret.
678
+
679
+ | Error | Raised when | Carries |
680
+ |---|---|---|
681
+ | `InvalidArgumentError` | a value cannot be used — an unknown signature format, an empty secret, an unavailable algorithm; also a `ValueError` | — |
682
+ | `LogicError` | this library was used in a way that cannot work — a chain that wrote no body, a signer running before the timestamp; also a `RuntimeError` | — |
683
+ | `RejectWebhookError` | an incoming webhook is refused; it carries the answer the sender gets | `.status_code`, `.headers` |
684
+ | `WebhookRuntimeError` | something a webhook depends on failed while it was handled; also a `RuntimeError` | — |
685
+ | `DeliveryError` | a receiver answered an outgoing webhook outside 200-299 | `.status_code`, `.url` |
686
+
687
+ An outgoing webhook nothing answered raises the client's own error instead, unwrapped:
688
+ `TransportError` from xtr-http-client-contracts, or its `TransportTimeoutError`. The
689
+ private-network guard refuses with a `TransportError` too.
690
+
691
+ ## Development
692
+
693
+ Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
694
+ `packages/xtr-webhook`; run the commands below from there. The `python-xtr-webhook`
695
+ repository is a read-only copy, so send issues and pull requests to the monorepo.
696
+
697
+ ```sh
698
+ uv sync --all-extras
699
+ uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
700
+ ```
701
+
702
+ ## License
703
+
704
+ MIT — see [LICENSE](LICENSE).