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,350 @@
1
+ ---
2
+ name: xtr-webhook
3
+ description: How to receive and send webhooks with xtr-webhook — verify an incoming request against a subscriber secret, bound it to a replay window, turn it into a remote event, and send signed webhooks through a message bus in the legacy or the Standard Webhooks format. Use when an application must accept a provider callback, verify an HMAC signature header, mount a webhook endpoint, write a parser for one sender, rotate a signing secret, choose between the legacy and Standard Webhooks signature, send signed webhooks to subscribers with retries, stop an outgoing request from reaching a private address or cloud metadata endpoint, test a parser against recorded payloads, or add WebhookBundle to an application.
4
+ ---
5
+
6
+ # xtr-webhook
7
+
8
+ A webhook is an HTTP request from someone who is not a user, so there is no session to trust,
9
+ the body is the only thing signed, and a request captured once can be sent again tomorrow. This
10
+ package verifies one, bounds it to a window, and turns it into an
11
+ [xtr-remote-event](https://github.com/xterr/python-xtr) event so the code that handles it never
12
+ sees HTTP. Sending is the same problem from the other side.
13
+
14
+ ## Quick reference
15
+
16
+ - Receive — `setup(app, kernel)` adds `POST /webhook/{type}` (route resource `webhook`, movable
17
+ in `<app>/config/routes/webhook.py`); the type picks the parser and the secret.
18
+ `WebhookController(parsers, secrets, bus)` is what answers.
19
+ - Parse — subclass `AbstractRequestParser` and fill in `get_request_matcher()` and
20
+ `async do_parse(request, secret)`. `RequestParser` is the built-in one. The matchers are
21
+ `xtr_http_foundation`'s, and `IsJsonRequestMatcher` reads the body rather than the
22
+ `Content-Type` header.
23
+ - Send — `await bus.dispatch(SendWebhookMessage(Subscriber(url, secret), event))`. Needs the
24
+ `client` extra, which brings xtr-http-client; `Transport` posts through any
25
+ `HttpClientInterface`.
26
+ - Formats — `SignatureFormat.LEGACY` (default), `STANDARD`, `TRANSITIONAL`. Members are their
27
+ strings. Only `STANDARD` signs the timestamp, so only it bounds replays.
28
+ - Guard — `NoPrivateNetworkConfig(enabled=True)` in the bundle, or
29
+ `NoPrivateNetworkHttpClient(client)` by hand. Opt-in, off by default.
30
+ - Answers — `202` read, `404` unknown type, `406` refused, `5xx` something behind the endpoint
31
+ failed.
32
+ - Test — `parser_cases`, `ParserCase`, `assert_parses` from `xtr_webhook.testing`.
33
+ - Every error derives from `WebhookError`.
34
+
35
+ ## Receive a webhook
36
+
37
+ With `xtr-webhook[di]`, call `setup` — it adds the endpoint — and route each sender's type:
38
+
39
+ ```python
40
+ from fastapi import FastAPI
41
+ from xtr_dependency_injection import Kernel
42
+ from xtr_http_kernel import setup
43
+
44
+ app = FastAPI()
45
+ setup(app, Kernel("app"))
46
+ ```
47
+
48
+ ```python
49
+ # app/config/webhook.py
50
+ from xtr_dependency_injection import configure, env
51
+
52
+ from xtr_webhook.bundle import RouteConfig, WebhookConfig
53
+
54
+
55
+ @configure
56
+ def webhook() -> WebhookConfig:
57
+ return WebhookConfig(
58
+ routing={
59
+ "acme": RouteConfig(secret=env("WEBHOOK_SECRET")),
60
+ "stripe": RouteConfig(parser=StripeRequestParser, secret=env("STRIPE_SECRET")),
61
+ },
62
+ )
63
+ ```
64
+
65
+ - One endpoint serves every sender, so a new sender is configuration, not another route. Give
66
+ each subscriber the URL its type is served at, built from the route's name `"webhook"`.
67
+ - `routes("/hooks")` moves the endpoint to `/hooks/webhook/{type}`.
68
+ - A route with no `parser` uses the bundle's own `RequestParser`, configured by the surrounding
69
+ `WebhookConfig`. Two routes naming one parser class share one instance.
70
+ - Secrets go through `env(...)`. A placeholder is read only when the endpoint is built, and no
71
+ container report prints it.
72
+ - The sender is answered before the events are handled — each event goes out as a
73
+ `ConsumeRemoteEventMessage` on the bus — because a sender waiting on a handler times out and
74
+ sends the webhook again.
75
+ - Routing keys are kept verbatim, dashes included.
76
+
77
+ Without the container, build the controller by hand over a mapping of parsers, a mapping of
78
+ secrets and a bus. See the package README's Quick start for a runnable one.
79
+
80
+ ## Write a parser
81
+
82
+ One parser speaks for one sender:
83
+
84
+ ```python
85
+ import hmac
86
+
87
+ from xtr_http_foundation import (
88
+ ChainRequestMatcher,
89
+ IsJsonRequestMatcher,
90
+ MethodRequestMatcher,
91
+ RequestMatcherInterface,
92
+ )
93
+ from xtr_http_message_contracts import ServerRequestInterface
94
+ from xtr_remote_event import RemoteEvent
95
+
96
+ from xtr_webhook.client import AbstractRequestParser
97
+ from xtr_webhook.exception import RejectWebhookError
98
+
99
+
100
+ class AcmeRequestParser(AbstractRequestParser):
101
+ def get_request_matcher(self) -> RequestMatcherInterface:
102
+ return ChainRequestMatcher([MethodRequestMatcher("POST"), IsJsonRequestMatcher()])
103
+
104
+ async def do_parse(self, request: ServerRequestInterface, secret: str) -> RemoteEvent:
105
+ body = await self.read_body(request)
106
+ expected = hmac.new(secret.encode(), body, "sha256").hexdigest()
107
+ if not hmac.compare_digest(expected, request.get_header_line("X-Acme-Signature")):
108
+ raise RejectWebhookError(406, "Signature is wrong.")
109
+ payload = await self.read_json(request)
110
+ return RemoteEvent("acme", str(payload["id"]), payload)
111
+ ```
112
+
113
+ The base class already does what every parser would otherwise rewrite: the matcher runs before
114
+ anything is read out of the request, a body that will not decode comes back as a `406` rather
115
+ than a `500`, and both answers are the ones the sender's side waits for.
116
+
117
+ - `await self.read_body(request)` returns every byte from the first one, rewinding the stream
118
+ where it can — a signature covers exactly what arrived. Reading a body is awaited.
119
+ - `await self.read_json(request)` decodes a JSON object. An array or a bare value is refused;
120
+ bytes that are not JSON raise, and `parse()` turns that into the same `406`.
121
+ - Return one event, a sequence, or `None` for a webhook to accept and ignore. `None` and an
122
+ empty sequence are answered as unparsable, not accepted.
123
+ - `RejectWebhookError(status_code=406, message="", headers=None)` carries the whole answer. The
124
+ message reaches the sender as plain text, so name the check that refused and nothing more.
125
+ - Override `create_successful_response` — an `async def`, so it can write a body — for a sender
126
+ that wants something echoed back; `create_rejected_response` is its counterpart for a refusal.
127
+ The response is handed in and given back modified; an HTTP message is immutable.
128
+ - Matchers come from `xtr_http_foundation`, not from this package: `MethodRequestMatcher(["POST",
129
+ "PUT"])` (one method or a sequence), `IsJsonRequestMatcher()`, `ChainRequestMatcher([...])`
130
+ (all must match), plus matchers over the path, host, port, scheme, a header, a query parameter
131
+ and the address it came from. `matches()` is awaited, so a rule may be about the body.
132
+ - `IsJsonRequestMatcher` reads the body, not the `Content-Type` header: a JSON body carrying no
133
+ header is claimed, and an `application/json` request whose body is not JSON is refused with
134
+ `Request does not match.` before the parser sees it.
135
+
136
+ ## Send a webhook
137
+
138
+ With `xtr-webhook[client]` installed:
139
+
140
+ ```python
141
+ from xtr_remote_event import RemoteEvent
142
+
143
+ from xtr_webhook.messenger import SendWebhookMessage
144
+ from xtr_webhook.subscriber import Subscriber
145
+
146
+ _ = await bus.dispatch(
147
+ SendWebhookMessage(
148
+ Subscriber("https://example.com/hook", secret),
149
+ RemoteEvent("order.paid", "evt_1", {"total": 4200}),
150
+ )
151
+ )
152
+ ```
153
+
154
+ - `Subscriber(url, secret)` is frozen and keeps the secret out of its `repr`. An empty secret
155
+ raises `InvalidArgumentError` — it would still sign, over the empty key.
156
+ - The chain is `HeadersConfigurator` → `JsonBodyConfigurator` → `HeaderSignatureConfigurator`,
157
+ and the signer runs last because it signs the other two's output. Replace or add a step for a
158
+ receiver that disagrees.
159
+ - `Transport(client, headers, body, signer)` posts through any `HttpClientInterface` — the
160
+ bundle's choice is the client `WebhookConfig.http_client` names, the default one when unset.
161
+ It asks for no redirect and passes no timeout: a delivery's timeout is the client's
162
+ (`default_options.timeout`, or a scoped client of its own).
163
+ - Any answer outside 200-299 raises `DeliveryError`. Nothing is caught, so the bus retries and,
164
+ once the attempts are spent, keeps the message. A redirect counts as refused and is never
165
+ followed. A delivery nothing answered raises the client's own `TransportError`, retried alike.
166
+ - Without the `client` extra no transport and no send handler are registered, and the dispatch
167
+ fails with the bus's no-handler error.
168
+ - The subscriber secret travels inside the queued message body, so the queue storage must be
169
+ trusted. The bus logs the message type, transport, id, handler name and stamps, never the body.
170
+
171
+ ## Choose a signature format
172
+
173
+ | Format | Header value | Signed over | Event name in | Replay bounded |
174
+ | --- | --- | --- | --- | --- |
175
+ | `LEGACY` | `sha256=<hex>` | name + id + body | the `Webhook-Event` header | no |
176
+ | `STANDARD` | `v1,<base64>` | `<id>.<timestamp>.` + body | the payload's `type` key | yes |
177
+ | `TRANSITIONAL` | both, space separated | both | both | for the Standard entry only |
178
+
179
+ - Both ends must have made the same choice. `TRANSITIONAL` is the migration window, so they need
180
+ not change on the same day.
181
+ - `LEGACY` carries no signed timestamp, so `timestamp_tolerance` protects only `STANDARD` and
182
+ `TRANSITIONAL`. Under `LEGACY` the timestamp header is sent but nothing signs it.
183
+ - Under `STANDARD` the `Webhook-Event` header is not sent at all; the name goes in the payload,
184
+ because that scheme's signature covers no header.
185
+ - A rotation works because the signature header may carry several whitespace-separated entries
186
+ and any one that verifies is enough.
187
+ - `SignatureFormat.parse("standard")` is the single door for a string; anything else raises
188
+ `InvalidArgumentError`.
189
+
190
+ Depth — the exact bytes signed, the `whsec_` secret encoding, the rotation sequence and the
191
+ literal requests each format emits — is in
192
+ [references/signature-formats.md](references/signature-formats.md).
193
+
194
+ ## Guard outbound requests
195
+
196
+ Opt-in, off by default. A subscriber URL is data, so without it a webhook is a way to make this
197
+ process fetch an address only this process can see.
198
+
199
+ In the bundle, around the client `http_client` names (the default one when unset):
200
+
201
+ ```python
202
+ from xtr_webhook.bundle import NoPrivateNetworkConfig, WebhookConfig
203
+
204
+ WebhookConfig(no_private_network=NoPrivateNetworkConfig(enabled=True, allow_list=("10.1.2.3",)))
205
+ ```
206
+
207
+ By hand, wrap the client the transport is given:
208
+
209
+ ```python
210
+ from xtr_http_client import HttpClient, NoPrivateNetworkHttpClient
211
+
212
+ from xtr_webhook.server import (
213
+ HeadersConfigurator,
214
+ HeaderSignatureConfigurator,
215
+ JsonBodyConfigurator,
216
+ JsonPayloadSerializer,
217
+ Transport,
218
+ )
219
+
220
+ transport = Transport(
221
+ NoPrivateNetworkHttpClient(HttpClient.create()),
222
+ HeadersConfigurator(),
223
+ JsonBodyConfigurator(JsonPayloadSerializer()),
224
+ HeaderSignatureConfigurator(),
225
+ )
226
+ ```
227
+
228
+ - The check runs on what the host *resolves to*, and the request is then pinned to the address
229
+ that was checked, so DNS rebinding connects to what was vetted or not at all.
230
+ - With no `subnets` it refuses the networks only this one can reach — loopback, private,
231
+ link-local, carrier-grade NAT, unique-local, multicast, reserved, unspecified.
232
+ - `subnets` replaces that set; `allow_list` opens one receiver without opening its network. Both
233
+ take IP networks or single addresses, never hostnames.
234
+ - A refusal is the client's `TransportError`, before anything is sent:
235
+ `Host "<host>" is blocked for "<url>".` or `IP "<ip>" is blocked for "<url>".`. Retrying
236
+ changes nothing.
237
+ - `subnets` or a non-empty `allow_list` with `enabled=False` is refused. Turning the guard on
238
+ without xtr-http-client installed raises `LogicError` when the container is built.
239
+
240
+ ## Testing
241
+
242
+ `xtr_webhook.testing` turns a directory of recorded payloads into tests. Before using it, install
243
+ the `testing` extra — the helpers build requests with xtr-http-message:
244
+ `uv add --dev "xtr-webhook[testing]"`.
245
+
246
+ Each `*.json` fixture has a sibling `*.py` defining a module-level `EXPECTED`, the event or list of
247
+ events it must parse to:
248
+
249
+ ```python
250
+ from pathlib import Path
251
+
252
+ import pytest
253
+
254
+ from xtr_webhook.testing import ParserCase, assert_parses, parser_cases
255
+
256
+
257
+ @pytest.mark.anyio
258
+ @pytest.mark.parametrize(
259
+ "case", parser_cases(Path(__file__).parent / "fixtures"), ids=lambda case: case.id
260
+ )
261
+ async def test_the_parser_reads_every_recorded_payload(case: ParserCase) -> None:
262
+ await assert_parses(AcmeRequestParser(), case, secret="the-secret")
263
+ ```
264
+
265
+ - `parser_cases(fixtures_dir, extension="json")` walks recursively and returns a
266
+ `ParserCase(id, payload, expected)` per fixture, sorted by id, so dropping a payload in the
267
+ directory adds a test. A fixture with no sibling raises `InvalidArgumentError` naming both
268
+ paths.
269
+ - `assert_parses(parser, case, *, secret="", headers=None, method="POST")` builds the request
270
+ with `Content-Type: application/json` plus `headers`, parses it, and asserts the events match.
271
+ Pass the signature headers a real delivery carries through `headers`.
272
+ - Nothing there imports pytest, so the helpers work under any runner.
273
+ - For the whole endpoint, build a `WebhookController` over a mapping of parsers and a recording
274
+ bus and call `index_action(type, request, Response())`; assert the status and what reached the
275
+ bus.
276
+ - For the sending side, give `Transport` a `MockHttpClient` from xtr-http-client over a
277
+ `MockResponse`, and read what was sent off the response: `get_request_url()`,
278
+ `get_request_options()["headers"]` and `["body"]`. `MockResponse(info={"http_code": 500})`
279
+ scripts a refusal. In an application, `HttpClientConfig(mock_response_factory=True)` makes the
280
+ `MockHttpClient` service the transport; script it with `set_response_factory`.
281
+ - Freeze time by passing a `MockClock` as `clock` to `HeadersConfigurator` or `RequestParser`, so
282
+ a timestamp and its window are decided rather than raced.
283
+
284
+ ## Use in an application
285
+
286
+ `uv run xtr-recipes recipes:sync` applies the recipe shipped with this package: it lists
287
+ `WebhookBundle`, writes a starting `<app>/config/webhook.py` with one route,
288
+ `<app>/config/routes/webhook.py`, and `WEBHOOK_SECRET` (commented out) in `.env`. It prints the
289
+ steps it cannot make — calling `setup` and naming your own types.
290
+
291
+ 1. **Install** — `uv add "xtr-webhook[client]"`; add `di` for the bundle. The endpoint comes
292
+ with the package; `client` sends, bringing xtr-http-client and its bundle.
293
+ 2. **Activate** — `WebhookBundle: {"all": True}` in `BUNDLES` in `<app>/bundles.py`
294
+ (`from xtr_webhook.bundle import WebhookBundle`).
295
+ 3. **Brings along** — the remote-event bundle, the messenger bundle through it, and the HTTP
296
+ kernel's bundle, always; the HTTP client bundle when xtr-http-client is installed.
297
+ 4. **Configure** — needed to receive anything: with no routing the endpoint knows no type and
298
+ answers every webhook `404`. `<app>/config/webhook.py`, a `@configure` function returning
299
+ `WebhookConfig` — `routing`, `signature_format`, `timestamp_tolerance`, `signing_algorithm`,
300
+ the four header names, `http_client`, `no_private_network`. A delivery's timeout is set on
301
+ the client, in `<app>/config/http_client.py`.
302
+ 5. **Environment** — `WEBHOOK_SECRET`, the secret the written route verifies against, read when
303
+ the endpoint is built; one per sender in practice. **Ignore** — nothing.
304
+ 6. **Load** — call `setup(app, kernel)`; it adds `POST /webhook/{type}`, arranged by
305
+ `<app>/config/routes/webhook.py` (`routes.import_("webhook").prefix("/hooks")` moves it).
306
+ 7. **Use** — declare a consumer for each type with
307
+ `@as_remote_event_consumer("<type>")`, and inject the bundle's parser where you need it with
308
+ `Annotated[RequestParserInterface, Target("webhook")]`.
309
+ 8. **Check** — `debug:bundles` shows `webhook` and `remote_event` as `listed` and `active`;
310
+ `debug:config webhook` shows the resolved routing, header names and signature format.
311
+ 9. **Remove** — drop the `BUNDLES` entry, delete `<app>/config/webhook.py` and
312
+ `<app>/config/routes/webhook.py`, then `uv remove xtr-webhook`.
313
+
314
+ ## Errors
315
+
316
+ Everything derives from `WebhookError` and carries typed attributes.
317
+
318
+ | Error | Raised when | Carries |
319
+ | --- | --- | --- |
320
+ | `InvalidArgumentError` | a value cannot be used — an unknown format, an empty secret, an unavailable algorithm; also a `ValueError` | — |
321
+ | `LogicError` | the library was used in a way that cannot work — a chain that wrote no body, a signer before the timestamp; also a `RuntimeError` | — |
322
+ | `RejectWebhookError` | an incoming webhook is refused; it carries the answer the sender gets | `.status_code`, `.headers` |
323
+ | `WebhookRuntimeError` | something a webhook depends on failed while it was handled | — |
324
+ | `DeliveryError` | a receiver answered outside 200-299 | `.status_code`, `.url` |
325
+
326
+ An `InvalidArgumentError` is configuration to fix, never an answer to a request. A
327
+ `RejectWebhookError` is the answer to a request and nothing else. A delivery nothing answered,
328
+ and one the private-network guard refused, raise the client's `TransportError`
329
+ (xtr-http-client-contracts) unwrapped.
330
+
331
+ ## Do not
332
+
333
+ - Do not compare a signature with `==`. Use `hmac.compare_digest`, so the time a refusal takes
334
+ says nothing about how much of it was right.
335
+ - Do not put the reason a check failed into a refusal message. Name the check; a sender is not
336
+ debugged by its receiver.
337
+ - Do not read the body with anything but `read_body`/`read_json`, and do not re-encode a payload
338
+ before verifying — the signature covers the bytes that arrived.
339
+ - Do not rely on `timestamp_tolerance` under `LEGACY`; that format signs no timestamp. Use
340
+ `STANDARD` where replay protection matters.
341
+ - Do not trust the `Webhook-Event` header under `STANDARD`. That signature covers no header; the
342
+ name is in the payload's `type`.
343
+ - Do not handle a webhook before answering its sender, and do not swallow a `DeliveryError` in a
344
+ handler — the error is what makes the bus retry.
345
+ - Do not follow redirects when sending, and do not bound a delivery anywhere but on its client —
346
+ set the timeout in the HTTP client configuration, not around `Transport`.
347
+ - Do not run with an empty route secret in production, and do not write a secret as a literal in
348
+ configuration — pass `env("...")`.
349
+ - Do not queue a webhook through storage you do not trust: the subscriber secret is in the
350
+ message body.
@@ -0,0 +1,189 @@
1
+ # Signature formats, byte for byte
2
+
3
+ What each format signs, what it puts on the wire, and how to move between them. Both ends of a
4
+ webhook must have made the same choice, which is why `SignatureFormat` is taken by the sender's
5
+ configurators and by the receiver's parser alike.
6
+
7
+ `SignatureFormat` is a string enum, so the members *are* their strings: a configuration file, an
8
+ option mapping or an environment variable carries `"legacy"`, `"standard"` or `"transitional"`
9
+ and is understood with no conversion step. `SignatureFormat.parse(value)` is the single door for
10
+ anything arriving from outside, and raises `InvalidArgumentError` with
11
+ `"<value>" is not a signature format; use "legacy", "standard" or "transitional".`
12
+
13
+ ## Legacy
14
+
15
+ The historical format, and the default, so an existing pair of endpoints keeps working.
16
+
17
+ Signed bytes are the three run together with nothing between them:
18
+
19
+ ```
20
+ event.name + event.id + body
21
+ ```
22
+
23
+ The header value names the digest, so a receiver knows what to recompute:
24
+
25
+ ```
26
+ Webhook-Signature: sha256=<hexdigest>
27
+ ```
28
+
29
+ Recomputing it by hand:
30
+
31
+ ```python
32
+ import hmac
33
+
34
+ expected = (
35
+ "sha256="
36
+ + hmac.new(
37
+ secret.encode(), event_name.encode() + event_id.encode() + body, "sha256"
38
+ ).hexdigest()
39
+ )
40
+ ```
41
+
42
+ A full delivery:
43
+
44
+ ```http
45
+ POST /hook HTTP/1.1
46
+ Content-Type: application/json
47
+ Webhook-Id: evt_1
48
+ Webhook-Timestamp: 1767225600
49
+ Webhook-Event: order.paid
50
+ Webhook-Signature: sha256=0f8c...
51
+
52
+ {"total":4200}
53
+ ```
54
+
55
+ `signing_algorithm` picks the digest; anything `hashlib` cannot make is refused where it is
56
+ configured rather than at the first delivery. The event name is in `Webhook-Event`, which this
57
+ signature covers, so the receiver may act on it.
58
+
59
+ **No timestamp is signed.** `Webhook-Timestamp` is sent, but nothing covers it, so refusing on
60
+ it would mean trusting a value an attacker sets. There is no replay protection in this format.
61
+
62
+ ## Standard Webhooks
63
+
64
+ The [standardwebhooks.com](https://www.standardwebhooks.com/) scheme. Always HMAC-SHA256 — the
65
+ scheme fixes the digest, so `signing_algorithm` does not apply to it.
66
+
67
+ Signed bytes, with the id and the timestamp dot-separated ahead of the body:
68
+
69
+ ```
70
+ f"{message_id}.{timestamp}.".encode() + body
71
+ ```
72
+
73
+ The header value is the version marker and the base64 of the digest:
74
+
75
+ ```
76
+ Webhook-Signature: v1,<base64>
77
+ ```
78
+
79
+ A full delivery. There is no `Webhook-Event` header at all, because this signature covers no
80
+ header, so a name sent as one is a value the receiver cannot trust. The name goes in the
81
+ payload's `type` key instead:
82
+
83
+ ```http
84
+ POST /hook HTTP/1.1
85
+ Content-Type: application/json
86
+ Webhook-Id: evt_1
87
+ Webhook-Timestamp: 1767225600
88
+ Webhook-Signature: v1,dGhlLXNpZ25hdHVyZQ==
89
+
90
+ {"total":4200,"type":"order.paid"}
91
+ ```
92
+
93
+ A payload that already carries `type` keeps what it says: the application knows its own events
94
+ better than their source name does. The payload is copied before the key is added, so sending a
95
+ webhook never edits the dict the caller is still holding. A payload whose `type` is missing or
96
+ not a string names no event and is refused with
97
+ `The payload is missing the "type" key holding the event name.`
98
+
99
+ ### The secret's encoding
100
+
101
+ The scheme writes a secret as base64 behind a `whsec_` prefix, so the key need not be text:
102
+
103
+ - `whsec_<base64>` — the key is those bytes, decoded.
104
+ - Anything else, including a `whsec_` prefix over something that is not valid base64, is its own
105
+ key. A receiver configured with a plain string must keep working, and a secret that happens to
106
+ start with those six characters is not a mistake to raise over.
107
+ - A prefix over nothing decodes to an empty key, which is no secret at all, so that falls back to
108
+ the secret itself too.
109
+
110
+ ### The replay window
111
+
112
+ Because the timestamp is signed, it can be held to a window around now: `timestamp_tolerance`,
113
+ 300 seconds by default, either side. Zero or less turns the bound off, for when clocks that
114
+ cannot be kept close enough matter more than a replayed capture.
115
+
116
+ The check runs only after a Standard Webhooks entry verified, which is when the timestamp is
117
+ known to be the sender's rather than an attacker's. A timestamp that does not read as an integer
118
+ is refused as well — a value that cannot be bounded is not accepted on faith. The refusal is
119
+ `The "Webhook-Timestamp" HTTP request header is outside the accepted window.`
120
+
121
+ ## Transitional
122
+
123
+ Both signatures, space separated in one header, with the event name in both the header and the
124
+ payload:
125
+
126
+ ```
127
+ Webhook-Signature: sha256=0f8c... v1,dGhlLXNpZ25hdHVyZQ==
128
+ ```
129
+
130
+ A receiver on this format accepts either entry, which is what lets the two ends migrate on
131
+ different days. Only the Standard Webhooks entry bounds replays, and a request carrying both is
132
+ tried Standard-first, so it is replay-bounded whatever order its entries arrive in.
133
+
134
+ ### Migrating
135
+
136
+ Legacy to Standard, with nothing refused in between:
137
+
138
+ 1. Set the **receiver** to `transitional`. It now accepts both, and still accepts everything the
139
+ sender sends today.
140
+ 2. Set the **sender** to `transitional`. Every delivery now carries both entries.
141
+ 3. Set the **receiver** to `standard`. Legacy entries stop being accepted; the sender is still
142
+ sending the Standard one too.
143
+ 4. Set the **sender** to `standard`. The legacy entry stops being emitted, and the
144
+ `Webhook-Event` header with it — so anything downstream reading that header must already read
145
+ the payload's `type`.
146
+
147
+ Backwards is the same four steps read upwards.
148
+
149
+ ## Rotating a secret
150
+
151
+ The signature header may carry several whitespace-separated entries, one per secret in play, and
152
+ **any one that verifies is enough**. So:
153
+
154
+ 1. The sender signs with both the old and the new secret, sending both entries.
155
+ 2. The receiver's configured secret is changed to the new one.
156
+ 3. The sender drops the old secret.
157
+
158
+ Each expectation is computed once, before its loop, so the number of entries a request carries
159
+ does not drive the CPU cost of rejecting it, and every comparison is `hmac.compare_digest`.
160
+
161
+ ## Header names
162
+
163
+ All four are configurable, on `WebhookConfig` and on each configurator and the parser:
164
+
165
+ | Option | Default | Read by |
166
+ | --- | --- | --- |
167
+ | `id_header_name` | `Webhook-Id` | both signatures |
168
+ | `timestamp_header_name` | `Webhook-Timestamp` | the Standard signature |
169
+ | `event_header_name` | `Webhook-Event` | the legacy signature |
170
+ | `signature_header_name` | `Webhook-Signature` | the entries themselves |
171
+
172
+ `HeadersConfigurator` writes the timestamp under the same name `HeaderSignatureConfigurator`
173
+ reads it back from, so changing one means changing both. The signer runs last; a format that
174
+ signs the timestamp, running before whatever wrote it, raises `LogicError` naming the header.
175
+
176
+ ## Which headers must be present
177
+
178
+ `RequestParser` refuses a request missing a header its format needs, with
179
+ `Missing "<name>" HTTP request signature header.`
180
+
181
+ | Format | Required |
182
+ | --- | --- |
183
+ | `LEGACY` | signature, id, event |
184
+ | `STANDARD` | signature, id, timestamp |
185
+ | `TRANSITIONAL` | signature, id |
186
+
187
+ Under `TRANSITIONAL` only the two shared headers are required, because which entry will verify
188
+ is not known yet; whichever scheme's own headers are absent simply cannot be the one that
189
+ authenticates the request.
@@ -0,0 +1,10 @@
1
+ """Webhooks received as events, and sent signed.
2
+
3
+ An incoming request is checked against the secret of the subscriber it claims to come from,
4
+ bounded to a window so a captured one cannot be replayed, and turned into an event. An
5
+ outgoing one is signed the same way and sent through a message bus, so a refusal is retried.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__: list[str] = []
@@ -0,0 +1,22 @@
1
+ """Where the endpoint receiving webhooks lives, and what its route is called.
2
+
3
+ The path and the name are written once, here, rather than at the one place that
4
+ mounts them: an application builds a URL to give a sender by the route's name, and
5
+ what it hands out has to be the path actually served. A sender that was told a URL
6
+ keeps sending to it long after the deploy that changed it.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Final
12
+
13
+ __all__ = ["ROUTE_NAME", "WEBHOOK_PATH"]
14
+
15
+ #: Where a webhook arrives. The segment is the type the webhook is for — which
16
+ #: subscriber sent it, and therefore which parser reads it and which secret it
17
+ #: is checked against.
18
+ WEBHOOK_PATH: Final = "/webhook/{type}"
19
+
20
+ #: What the route is called, so an application can build the URL it gives a
21
+ #: sender rather than writing the path out again.
22
+ ROUTE_NAME: Final = "webhook"
@@ -0,0 +1,76 @@
1
+ """The Standard Webhooks signature scheme, shared by the sender and the receiver.
2
+
3
+ One module rather than one on each side, because the two must agree byte for byte:
4
+ https://www.standardwebhooks.com/. It only makes a signature. Checking one against the
5
+ header a request carries is the receiver's job, and it compares with
6
+ :func:`hmac.compare_digest`, never ``==``, so the time the comparison takes says nothing
7
+ about how much of the signature was right.
8
+
9
+ Private to this library: the scheme is an implementation detail of the configurator that
10
+ emits it and the parser that reads it, and nothing outside has a reason to produce one.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import base64
16
+ import binascii
17
+ import hashlib
18
+ import hmac
19
+ from typing import Final
20
+
21
+ __all__ = ["PREFIX", "sign"]
22
+
23
+ PREFIX: Final = "v1,"
24
+ """The scheme's version marker, which every signature it makes starts with.
25
+
26
+ A receiver reads it to tell this scheme's entry apart from the others a header may
27
+ carry, and a later version of the scheme changes this one constant.
28
+ """
29
+
30
+ _WHSEC_PREFIX: Final = "whsec_"
31
+ """How the scheme spells a secret whose key is base64 rather than the text itself."""
32
+
33
+
34
+ def sign(message_id: str, timestamp: str, body: bytes, secret: str) -> str:
35
+ """Return the signature for one webhook, prefixed with the scheme's version.
36
+
37
+ The id, the timestamp and the body are signed together, each separated by a dot,
38
+ so neither moving a signed body to another delivery nor replaying yesterday's
39
+ delivery keeps the signature valid.
40
+
41
+ Args:
42
+ message_id: The webhook's id, as the ``Webhook-Id`` header carries it.
43
+ timestamp: Seconds since the epoch, as the ``Webhook-Timestamp`` header
44
+ carries it — a string, so the signature covers exactly what is sent.
45
+ body: The bytes of the body, signed as they go out: re-encoding a payload
46
+ can reorder keys or respace them, and the receiver signs what arrived.
47
+ secret: The subscriber's secret.
48
+
49
+ Returns:
50
+ ``"v1,"`` followed by the base64 of the HMAC-SHA256 digest.
51
+ """
52
+ signed = hmac.new(_key(secret), f"{message_id}.{timestamp}.".encode() + body, hashlib.sha256)
53
+
54
+ return PREFIX + base64.b64encode(signed.digest()).decode()
55
+
56
+
57
+ def _key(secret: str) -> bytes:
58
+ """Return the key ``secret`` stands for.
59
+
60
+ The scheme writes a secret as base64 behind a ``whsec_`` prefix, so the key is
61
+ bytes that need not be text. A secret written any other way is its own key: a
62
+ receiver configured with a plain string must keep working, and a prefix over
63
+ something that is not base64 is a secret that happens to start with those six
64
+ characters, not a mistake to raise over. A prefix over nothing decodes to an empty
65
+ key, and an empty HMAC key is no secret at all, so that case falls back to the
66
+ secret itself as well.
67
+ """
68
+ if not secret.startswith(_WHSEC_PREFIX):
69
+ return secret.encode()
70
+
71
+ try:
72
+ decoded = base64.b64decode(secret[len(_WHSEC_PREFIX) :], validate=True)
73
+ except binascii.Error:
74
+ return secret.encode()
75
+
76
+ return decoded or secret.encode()
@@ -0,0 +1,15 @@
1
+ """The xtr-dependency-injection bundle for xtr-webhook."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .no_private_network_config import NoPrivateNetworkConfig
6
+ from .route_config import RouteConfig
7
+ from .webhook_bundle import WebhookBundle
8
+ from .webhook_config import WebhookConfig
9
+
10
+ __all__ = [
11
+ "NoPrivateNetworkConfig",
12
+ "RouteConfig",
13
+ "WebhookBundle",
14
+ "WebhookConfig",
15
+ ]