webhook-replay 0.1.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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fernando Aporta Franco (ferinazumaDEV)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,275 @@
1
+ Metadata-Version: 2.4
2
+ Name: webhook-replay
3
+ Version: 0.1.0
4
+ Summary: Capture, inspect and re-fire webhooks locally — a zero-dependency dev tool for debugging webhook integrations.
5
+ Author-email: Fernando Aporta Franco <ferinazumaDEV@users.noreply.github.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ferinazumaDEV/webhook-replay
8
+ Project-URL: Source, https://github.com/ferinazumaDEV/webhook-replay
9
+ Keywords: webhook,http,debugging,developer-tools,replay,cli
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Software Development :: Debuggers
16
+ Classifier: Topic :: Internet :: WWW/HTTP
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=7.0; extra == "dev"
22
+ Dynamic: license-file
23
+
24
+ # webhook-replay
25
+
26
+ ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
27
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
28
+ ![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
29
+
30
+ **Capture a webhook once, then re-fire it at your local app as many times as you need — without re-triggering the real event upstream.**
31
+
32
+ Debugging a webhook integration usually means going back to Stripe / GitHub / Shopify and manually re-sending the event every time you tweak your handler. `webhook-replay` breaks that loop: run a local endpoint that records every incoming request (headers, body, timestamp), then replay any captured request into your app on demand — with filtering, `curl` export, and payload diffing along the way.
33
+
34
+ Zero runtime dependencies. Pure Python standard library.
35
+
36
+ ---
37
+
38
+ ## Features
39
+
40
+ - **Capture everything** — accepts any method on any path, storing full headers, raw body, source address and timestamp in a local SQLite file.
41
+ - **Replay to your app** — re-send a captured request (or the last _N_, or the same one _N_ times) to any base URL. Non-2xx responses are reported, not swallowed.
42
+ - **Readable CLI** — colorized `list` / `show` with pretty-printed JSON bodies, or `--json` for scripting.
43
+ - **`curl` export** — turn any captured request into a copy-pasteable `curl` command.
44
+ - **Diff payloads** — compare two captured bodies; JSON is canonicalized first, so key-order noise disappears and only real changes show.
45
+ - **Filters** — by method, path substring, or age (`--since 10m`).
46
+ - **Secrets masked on output** — `Authorization`, `Cookie`, signature and API-key headers are printed as `<redacted>` in `show`, `list --json` and `curl` export, so a pasted command is safe. `--show-secrets` opts out; replay always sends the real values.
47
+ - **Bounded by default** — a body-size cap (`413` above it), a retention cap that evicts the oldest captures, and a read timeout, so a stray sender cannot fill your disk or pin a thread.
48
+ - **Nothing to install but Python** — no framework, no broker, no external service.
49
+
50
+ ---
51
+
52
+ ## Install
53
+
54
+ ```bash
55
+ git clone https://github.com/ferinazumaDEV/webhook-replay
56
+ cd webhook-replay
57
+ pip install .
58
+ ```
59
+
60
+ Or run it straight from a checkout without installing:
61
+
62
+ ```bash
63
+ python -m webhook_replay --help
64
+ ```
65
+
66
+ Requires Python 3.9+.
67
+
68
+ ---
69
+
70
+ ## Usage
71
+
72
+ ### 1. Start the capture server
73
+
74
+ ```console
75
+ $ webhook-replay serve --port 8973
76
+ webhook-replay listening on http://127.0.0.1:8973
77
+ storing captures in /home/you/.webhook-replay/captures.db
78
+ limits: body 1048576 bytes, retain 1000 newest, read timeout 30s
79
+ point your webhook here, then Ctrl-C to stop
80
+ 22:42:34 #1 POST /github/webhook (34 bytes)
81
+ 22:42:34 #2 POST /stripe/webhook (27 bytes)
82
+ 22:42:34 #3 GET /health (0 bytes)
83
+ ```
84
+
85
+ Point your provider's webhook (or a tunnel like ngrok/cloudflared) at this endpoint. Every request is logged live and persisted.
86
+
87
+ ### 2. List what came in
88
+
89
+ ```console
90
+ $ webhook-replay list
91
+ #3 22:41:43 GET /health
92
+ 0 bytes - from 127.0.0.1
93
+ #2 22:41:43 POST /stripe/webhook?livemode=false
94
+ 74 bytes application/json from 127.0.0.1
95
+ #1 22:41:43 POST /stripe/webhook?livemode=false
96
+ 74 bytes application/json from 127.0.0.1
97
+
98
+ 3 request(s).
99
+ ```
100
+
101
+ Filter it: `webhook-replay list --method POST --path stripe --since 10m`, or add `--json` to pipe it elsewhere.
102
+
103
+ ### 3. Inspect one request
104
+
105
+ ```console
106
+ $ webhook-replay show 1
107
+ POST /stripe/webhook?livemode=false
108
+ #1 2026-08-21T22:41:43.423+00:00 from 127.0.0.1
109
+
110
+ Headers
111
+ Content-Type: application/json
112
+ Stripe-Signature: <redacted>
113
+ ...
114
+ (some header values are masked; re-run with --show-secrets to see them)
115
+
116
+ Body
117
+ {
118
+ "id": "evt_1",
119
+ "type": "invoice.paid",
120
+ "amount": 4200,
121
+ "currency": "usd"
122
+ }
123
+ ```
124
+
125
+ ### 4. Replay it into your app
126
+
127
+ ```console
128
+ $ webhook-replay replay 1 --to http://127.0.0.1:8972 --show-response
129
+ #1 POST http://127.0.0.1:8972/stripe/webhook?livemode=false -> 200 OK 3ms
130
+ {"handled": true}
131
+ ```
132
+
133
+ Your local handler receives a byte-for-byte copy of the original request (one caveat, repeated header names, is noted under [How it works](#how-it-works)):
134
+
135
+ ```
136
+ [my-app] received POST /stripe/webhook?livemode=false -> {"id": "evt_1", "type": "invoice.paid", "amount": 4200, "currency": "usd"}
137
+ ```
138
+
139
+ Replay the two most recent, twice each, in one shot:
140
+
141
+ ```console
142
+ $ webhook-replay replay --last 2 --to http://127.0.0.1:8972 --times 2
143
+ #2 (x1) POST http://127.0.0.1:8972/stripe/webhook?livemode=false -> 200 OK 3ms
144
+ #2 (x2) POST http://127.0.0.1:8972/stripe/webhook?livemode=false -> 200 OK 1ms
145
+ #3 (x1) GET http://127.0.0.1:8972/health -> 501 Unsupported method ('GET') 1ms
146
+ #3 (x2) GET http://127.0.0.1:8972/health -> 501 Unsupported method ('GET') 1ms
147
+ ```
148
+
149
+ You can override the method (`--method POST`) or inject extra headers (`--header 'X-Debug: 1'`) on replay.
150
+
151
+ ### 5. Export a request as curl
152
+
153
+ ```console
154
+ $ webhook-replay curl 2 --base https://api.myapp.local
155
+ curl -X POST 'https://api.myapp.local/stripe/webhook?livemode=false' \
156
+ -H 'Content-Type: application/json' \
157
+ -H 'Stripe-Signature: <redacted>' \
158
+ --data-binary '{"id": "evt_2", "type": "invoice.paid", "amount": 9900, "currency": "eur"}'
159
+ ```
160
+
161
+ Signature, `Authorization`, `Cookie` and API-key headers are masked so the command is safe to paste into an issue or a chat. Add `--show-secrets` when you need a command that actually authenticates.
162
+
163
+ ### 6. Diff two payloads
164
+
165
+ ```console
166
+ $ webhook-replay diff 1 2
167
+ --- #1 (POST /stripe/webhook?livemode=false)
168
+ +++ #2 (POST /stripe/webhook?livemode=false)
169
+ @@ -1,6 +1,6 @@
170
+ {
171
+ - "amount": 4200,
172
+ - "currency": "usd",
173
+ - "id": "evt_1",
174
+ + "amount": 9900,
175
+ + "currency": "eur",
176
+ + "id": "evt_2",
177
+ "type": "invoice.paid"
178
+ }
179
+ ```
180
+
181
+ Both bodies are canonicalized as sorted-key JSON before diffing, so reordered keys don't show up as changes — only real value differences do.
182
+
183
+ ---
184
+
185
+ ## Command reference
186
+
187
+ | Command | What it does |
188
+ | --- | --- |
189
+ | `serve` | Start the local capture endpoint (`--host`, `--port`, `--status`, `--response`, `--max-body`, `--max-captures`, `--read-timeout`). |
190
+ | `list` | List captured requests (`--method`, `--path`, `--since`, `--limit`, `--json`, `--show-secrets`). |
191
+ | `show <id>` | Show one request in full (`--raw` writes just the body to stdout, `--show-secrets` unmasks headers). |
192
+ | `replay <id...>` | Replay request(s) to `--to <url>` (`--last N`, `--times N`, `--method`, `--header`, `--show-response`). |
193
+ | `curl <id>` | Print a request as a `curl` command (`--base <url>`, `--show-secrets`). |
194
+ | `diff <a> <b>` | Diff two captured bodies. |
195
+ | `prune` | Delete old captures (`--older-than 7d`, `--keep N`). |
196
+ | `clear` | Delete all captured requests (`-y` to skip the prompt). |
197
+
198
+ Global: `--db <path>` to use an alternate store, `--no-color` to disable ANSI colors (also honored via `NO_COLOR`).
199
+
200
+ ---
201
+
202
+ ## Security
203
+
204
+ **The capture store holds real credentials in clear text.** Every request is saved exactly as it arrived — full headers and full body — in a local SQLite file (`~/.webhook-replay/captures.db` by default). Webhook traffic routinely carries `Authorization` headers, session cookies, signing signatures and API keys, and all of it lands in that file unencrypted. That is deliberate: a replay is only faithful, and a signature only verifies, if the stored bytes are the original ones.
205
+
206
+ Because the store is not sanitised, the *output* is:
207
+
208
+ - `show`, `list --json` and `curl` mask the values of `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and any header whose name contains `secret`, `token`, `signature`, `hmac`, `api-key` / `api_key`, or a `sig` segment (`X-Shopify-Hmac-Sha256`, `Paypal-Transmission-Sig`). They print as `<redacted>`, keeping a recognisable scheme prefix where there is one (`Bearer <redacted>`).
209
+ - `--show-secrets` turns masking off for a single command, when you genuinely need the value.
210
+ - `replay` is never masked. It forwards the captured headers verbatim, which is the whole point of the tool.
211
+ - Masking is name-based, not value-based, and it covers the output paths only. It is a guard against pasting a secret into an issue or a screen share — not a guarantee that no secret can appear anywhere.
212
+
213
+ Handling the store:
214
+
215
+ - **Do not commit it and do not share it.** `.gitignore` already excludes `*.db`, `*.sqlite3` and `captures.db`, but a store kept outside the repo is safer still.
216
+ - **Delete it when you are done.** `webhook-replay clear -y` empties it; `webhook-replay prune --older-than 7d` drops everything older than a week; `rm ~/.webhook-replay/captures.db` removes the file outright.
217
+ - **Keep the server local.** It binds `127.0.0.1` by default and answers *every* method on *every* path with a canned success. Exposing it (`--host 0.0.0.0`, or a tunnel left running) turns it into an open, unauthenticated sink for anything on the network.
218
+ - **The defaults are bounded, not zero.** `serve` refuses bodies over 1 MiB with `413`, retains the 1000 newest captures, and drops a connection that stalls for 30 seconds. Tune them with `--max-body`, `--max-captures` and `--read-timeout`; `0` disables any of the three.
219
+
220
+ To report a vulnerability, see [SECURITY.md](SECURITY.md).
221
+
222
+ ---
223
+
224
+ ## How it works
225
+
226
+ - **Capture** — a threaded `http.server` handler is registered for every HTTP method. It reads the body — `Content-Length` bytes, or a `Transfer-Encoding: chunked` stream decoded on the way in — snapshots the headers in order, and writes a row to SQLite. A request that carries both `Content-Length` and `Transfer-Encoding`, or a malformed length, is refused with `400` rather than guessed at. It then answers with a configurable canned response (default `200 {"received": true}`) so the sender is satisfied, plus an `X-Webhook-Replay-Id` header echoing the stored id. Three bounds apply before anything is stored: an oversized body is refused with `413` without being buffered (for a chunked body, as soon as the declared chunks pass the cap), a connection that stalls mid-body hits the socket timeout and is dropped, and once the retention cap is reached each new capture evicts the oldest row. Every response closes the connection, so one connection serves exactly one request.
227
+ - **Store** — one SQLite table, one short-lived connection per operation (with a busy timeout), which keeps it safe under the server's per-request threads. Bodies are stored as `BLOB`, so binary payloads round-trip exactly.
228
+ - **Replay** — the stored method, path (including query string) and raw body are rebuilt into a `urllib` request against your target base URL. Hop-by-hop headers that describe the *original* connection (`Host`, `Content-Length`, `Connection`, `Accept-Encoding`) are dropped and recomputed; everything else — including signature headers — is forwarded verbatim, with one limitation: repeated header names are collapsed to the last value, because `urllib` keeps a single value per header name. A non-2xx reply is captured and reported rather than raised.
229
+ - **Redact** — masking lives in one module and runs only where a capture is *rendered*: `curl` export, `list --json` and `show`. Nothing filters the capture path or the replay path, so what is stored and what is re-sent stay byte-for-byte original.
230
+
231
+ Everything is standard library: `http.server`, `sqlite3`, `urllib`, `argparse`, `difflib`, `json`, `shlex`.
232
+
233
+ ---
234
+
235
+ ## Development
236
+
237
+ ```bash
238
+ python -m venv .venv && source .venv/bin/activate
239
+ pip install -e '.[dev]'
240
+ pytest
241
+ ```
242
+
243
+ The test suite (153 tests) is self-contained: it spins real capture and receiver servers on OS-assigned free ports and exercises capture (including chunked bodies and malformed framing), persistence, concurrent writes, the size / retention / timeout limits, replay (success, non-2xx, connection error, stalled target, method/header overrides, verbatim forwarding of sensitive headers), every CLI command, output redaction, `curl` export and JSON diffing end-to-end. No network access or external services required.
244
+
245
+ ```console
246
+ $ pytest
247
+ ........................................................................ [ 47%]
248
+ ........................................................................ [ 94%]
249
+ ......... [100%]
250
+ 153 passed in 22.55s
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Part of a family of small developer tools
256
+
257
+ `webhook-replay` is one of a family of small, dependency-light developer tools I build and maintain in the open — each a focused, standalone utility meant to do one job well. If this one was useful, these siblings might be too:
258
+
259
+ - [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
260
+ - [politeclient](https://github.com/ferinazumaDEV/politeclient) — a polite, bulletproof HTTP client for Python: retries with backoff, per-host rate-limiting, caching and pagination.
261
+ - [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
262
+ - [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from any LLM: schema-validated JSON with tolerant repair and retries.
263
+ - Hub & writing: [zentimes.es](https://zentimes.es).
264
+
265
+ By [ferinazumaDEV](https://github.com/ferinazumaDEV).
266
+
267
+ ---
268
+
269
+ ## License
270
+
271
+ MIT — see [LICENSE](LICENSE).
272
+
273
+ ---
274
+
275
+ _Built by Fernando Aporta Franco ([@ferinazumaDEV](https://github.com/ferinazumaDEV))._
@@ -0,0 +1,252 @@
1
+ # webhook-replay
2
+
3
+ ![Python](https://img.shields.io/badge/python-3.9%2B-blue)
4
+ ![License: MIT](https://img.shields.io/badge/license-MIT-green)
5
+ ![dependencies](https://img.shields.io/badge/dependencies-0-brightgreen)
6
+
7
+ **Capture a webhook once, then re-fire it at your local app as many times as you need — without re-triggering the real event upstream.**
8
+
9
+ Debugging a webhook integration usually means going back to Stripe / GitHub / Shopify and manually re-sending the event every time you tweak your handler. `webhook-replay` breaks that loop: run a local endpoint that records every incoming request (headers, body, timestamp), then replay any captured request into your app on demand — with filtering, `curl` export, and payload diffing along the way.
10
+
11
+ Zero runtime dependencies. Pure Python standard library.
12
+
13
+ ---
14
+
15
+ ## Features
16
+
17
+ - **Capture everything** — accepts any method on any path, storing full headers, raw body, source address and timestamp in a local SQLite file.
18
+ - **Replay to your app** — re-send a captured request (or the last _N_, or the same one _N_ times) to any base URL. Non-2xx responses are reported, not swallowed.
19
+ - **Readable CLI** — colorized `list` / `show` with pretty-printed JSON bodies, or `--json` for scripting.
20
+ - **`curl` export** — turn any captured request into a copy-pasteable `curl` command.
21
+ - **Diff payloads** — compare two captured bodies; JSON is canonicalized first, so key-order noise disappears and only real changes show.
22
+ - **Filters** — by method, path substring, or age (`--since 10m`).
23
+ - **Secrets masked on output** — `Authorization`, `Cookie`, signature and API-key headers are printed as `<redacted>` in `show`, `list --json` and `curl` export, so a pasted command is safe. `--show-secrets` opts out; replay always sends the real values.
24
+ - **Bounded by default** — a body-size cap (`413` above it), a retention cap that evicts the oldest captures, and a read timeout, so a stray sender cannot fill your disk or pin a thread.
25
+ - **Nothing to install but Python** — no framework, no broker, no external service.
26
+
27
+ ---
28
+
29
+ ## Install
30
+
31
+ ```bash
32
+ git clone https://github.com/ferinazumaDEV/webhook-replay
33
+ cd webhook-replay
34
+ pip install .
35
+ ```
36
+
37
+ Or run it straight from a checkout without installing:
38
+
39
+ ```bash
40
+ python -m webhook_replay --help
41
+ ```
42
+
43
+ Requires Python 3.9+.
44
+
45
+ ---
46
+
47
+ ## Usage
48
+
49
+ ### 1. Start the capture server
50
+
51
+ ```console
52
+ $ webhook-replay serve --port 8973
53
+ webhook-replay listening on http://127.0.0.1:8973
54
+ storing captures in /home/you/.webhook-replay/captures.db
55
+ limits: body 1048576 bytes, retain 1000 newest, read timeout 30s
56
+ point your webhook here, then Ctrl-C to stop
57
+ 22:42:34 #1 POST /github/webhook (34 bytes)
58
+ 22:42:34 #2 POST /stripe/webhook (27 bytes)
59
+ 22:42:34 #3 GET /health (0 bytes)
60
+ ```
61
+
62
+ Point your provider's webhook (or a tunnel like ngrok/cloudflared) at this endpoint. Every request is logged live and persisted.
63
+
64
+ ### 2. List what came in
65
+
66
+ ```console
67
+ $ webhook-replay list
68
+ #3 22:41:43 GET /health
69
+ 0 bytes - from 127.0.0.1
70
+ #2 22:41:43 POST /stripe/webhook?livemode=false
71
+ 74 bytes application/json from 127.0.0.1
72
+ #1 22:41:43 POST /stripe/webhook?livemode=false
73
+ 74 bytes application/json from 127.0.0.1
74
+
75
+ 3 request(s).
76
+ ```
77
+
78
+ Filter it: `webhook-replay list --method POST --path stripe --since 10m`, or add `--json` to pipe it elsewhere.
79
+
80
+ ### 3. Inspect one request
81
+
82
+ ```console
83
+ $ webhook-replay show 1
84
+ POST /stripe/webhook?livemode=false
85
+ #1 2026-08-21T22:41:43.423+00:00 from 127.0.0.1
86
+
87
+ Headers
88
+ Content-Type: application/json
89
+ Stripe-Signature: <redacted>
90
+ ...
91
+ (some header values are masked; re-run with --show-secrets to see them)
92
+
93
+ Body
94
+ {
95
+ "id": "evt_1",
96
+ "type": "invoice.paid",
97
+ "amount": 4200,
98
+ "currency": "usd"
99
+ }
100
+ ```
101
+
102
+ ### 4. Replay it into your app
103
+
104
+ ```console
105
+ $ webhook-replay replay 1 --to http://127.0.0.1:8972 --show-response
106
+ #1 POST http://127.0.0.1:8972/stripe/webhook?livemode=false -> 200 OK 3ms
107
+ {"handled": true}
108
+ ```
109
+
110
+ Your local handler receives a byte-for-byte copy of the original request (one caveat, repeated header names, is noted under [How it works](#how-it-works)):
111
+
112
+ ```
113
+ [my-app] received POST /stripe/webhook?livemode=false -> {"id": "evt_1", "type": "invoice.paid", "amount": 4200, "currency": "usd"}
114
+ ```
115
+
116
+ Replay the two most recent, twice each, in one shot:
117
+
118
+ ```console
119
+ $ webhook-replay replay --last 2 --to http://127.0.0.1:8972 --times 2
120
+ #2 (x1) POST http://127.0.0.1:8972/stripe/webhook?livemode=false -> 200 OK 3ms
121
+ #2 (x2) POST http://127.0.0.1:8972/stripe/webhook?livemode=false -> 200 OK 1ms
122
+ #3 (x1) GET http://127.0.0.1:8972/health -> 501 Unsupported method ('GET') 1ms
123
+ #3 (x2) GET http://127.0.0.1:8972/health -> 501 Unsupported method ('GET') 1ms
124
+ ```
125
+
126
+ You can override the method (`--method POST`) or inject extra headers (`--header 'X-Debug: 1'`) on replay.
127
+
128
+ ### 5. Export a request as curl
129
+
130
+ ```console
131
+ $ webhook-replay curl 2 --base https://api.myapp.local
132
+ curl -X POST 'https://api.myapp.local/stripe/webhook?livemode=false' \
133
+ -H 'Content-Type: application/json' \
134
+ -H 'Stripe-Signature: <redacted>' \
135
+ --data-binary '{"id": "evt_2", "type": "invoice.paid", "amount": 9900, "currency": "eur"}'
136
+ ```
137
+
138
+ Signature, `Authorization`, `Cookie` and API-key headers are masked so the command is safe to paste into an issue or a chat. Add `--show-secrets` when you need a command that actually authenticates.
139
+
140
+ ### 6. Diff two payloads
141
+
142
+ ```console
143
+ $ webhook-replay diff 1 2
144
+ --- #1 (POST /stripe/webhook?livemode=false)
145
+ +++ #2 (POST /stripe/webhook?livemode=false)
146
+ @@ -1,6 +1,6 @@
147
+ {
148
+ - "amount": 4200,
149
+ - "currency": "usd",
150
+ - "id": "evt_1",
151
+ + "amount": 9900,
152
+ + "currency": "eur",
153
+ + "id": "evt_2",
154
+ "type": "invoice.paid"
155
+ }
156
+ ```
157
+
158
+ Both bodies are canonicalized as sorted-key JSON before diffing, so reordered keys don't show up as changes — only real value differences do.
159
+
160
+ ---
161
+
162
+ ## Command reference
163
+
164
+ | Command | What it does |
165
+ | --- | --- |
166
+ | `serve` | Start the local capture endpoint (`--host`, `--port`, `--status`, `--response`, `--max-body`, `--max-captures`, `--read-timeout`). |
167
+ | `list` | List captured requests (`--method`, `--path`, `--since`, `--limit`, `--json`, `--show-secrets`). |
168
+ | `show <id>` | Show one request in full (`--raw` writes just the body to stdout, `--show-secrets` unmasks headers). |
169
+ | `replay <id...>` | Replay request(s) to `--to <url>` (`--last N`, `--times N`, `--method`, `--header`, `--show-response`). |
170
+ | `curl <id>` | Print a request as a `curl` command (`--base <url>`, `--show-secrets`). |
171
+ | `diff <a> <b>` | Diff two captured bodies. |
172
+ | `prune` | Delete old captures (`--older-than 7d`, `--keep N`). |
173
+ | `clear` | Delete all captured requests (`-y` to skip the prompt). |
174
+
175
+ Global: `--db <path>` to use an alternate store, `--no-color` to disable ANSI colors (also honored via `NO_COLOR`).
176
+
177
+ ---
178
+
179
+ ## Security
180
+
181
+ **The capture store holds real credentials in clear text.** Every request is saved exactly as it arrived — full headers and full body — in a local SQLite file (`~/.webhook-replay/captures.db` by default). Webhook traffic routinely carries `Authorization` headers, session cookies, signing signatures and API keys, and all of it lands in that file unencrypted. That is deliberate: a replay is only faithful, and a signature only verifies, if the stored bytes are the original ones.
182
+
183
+ Because the store is not sanitised, the *output* is:
184
+
185
+ - `show`, `list --json` and `curl` mask the values of `Authorization`, `Proxy-Authorization`, `Cookie`, `Set-Cookie` and any header whose name contains `secret`, `token`, `signature`, `hmac`, `api-key` / `api_key`, or a `sig` segment (`X-Shopify-Hmac-Sha256`, `Paypal-Transmission-Sig`). They print as `<redacted>`, keeping a recognisable scheme prefix where there is one (`Bearer <redacted>`).
186
+ - `--show-secrets` turns masking off for a single command, when you genuinely need the value.
187
+ - `replay` is never masked. It forwards the captured headers verbatim, which is the whole point of the tool.
188
+ - Masking is name-based, not value-based, and it covers the output paths only. It is a guard against pasting a secret into an issue or a screen share — not a guarantee that no secret can appear anywhere.
189
+
190
+ Handling the store:
191
+
192
+ - **Do not commit it and do not share it.** `.gitignore` already excludes `*.db`, `*.sqlite3` and `captures.db`, but a store kept outside the repo is safer still.
193
+ - **Delete it when you are done.** `webhook-replay clear -y` empties it; `webhook-replay prune --older-than 7d` drops everything older than a week; `rm ~/.webhook-replay/captures.db` removes the file outright.
194
+ - **Keep the server local.** It binds `127.0.0.1` by default and answers *every* method on *every* path with a canned success. Exposing it (`--host 0.0.0.0`, or a tunnel left running) turns it into an open, unauthenticated sink for anything on the network.
195
+ - **The defaults are bounded, not zero.** `serve` refuses bodies over 1 MiB with `413`, retains the 1000 newest captures, and drops a connection that stalls for 30 seconds. Tune them with `--max-body`, `--max-captures` and `--read-timeout`; `0` disables any of the three.
196
+
197
+ To report a vulnerability, see [SECURITY.md](SECURITY.md).
198
+
199
+ ---
200
+
201
+ ## How it works
202
+
203
+ - **Capture** — a threaded `http.server` handler is registered for every HTTP method. It reads the body — `Content-Length` bytes, or a `Transfer-Encoding: chunked` stream decoded on the way in — snapshots the headers in order, and writes a row to SQLite. A request that carries both `Content-Length` and `Transfer-Encoding`, or a malformed length, is refused with `400` rather than guessed at. It then answers with a configurable canned response (default `200 {"received": true}`) so the sender is satisfied, plus an `X-Webhook-Replay-Id` header echoing the stored id. Three bounds apply before anything is stored: an oversized body is refused with `413` without being buffered (for a chunked body, as soon as the declared chunks pass the cap), a connection that stalls mid-body hits the socket timeout and is dropped, and once the retention cap is reached each new capture evicts the oldest row. Every response closes the connection, so one connection serves exactly one request.
204
+ - **Store** — one SQLite table, one short-lived connection per operation (with a busy timeout), which keeps it safe under the server's per-request threads. Bodies are stored as `BLOB`, so binary payloads round-trip exactly.
205
+ - **Replay** — the stored method, path (including query string) and raw body are rebuilt into a `urllib` request against your target base URL. Hop-by-hop headers that describe the *original* connection (`Host`, `Content-Length`, `Connection`, `Accept-Encoding`) are dropped and recomputed; everything else — including signature headers — is forwarded verbatim, with one limitation: repeated header names are collapsed to the last value, because `urllib` keeps a single value per header name. A non-2xx reply is captured and reported rather than raised.
206
+ - **Redact** — masking lives in one module and runs only where a capture is *rendered*: `curl` export, `list --json` and `show`. Nothing filters the capture path or the replay path, so what is stored and what is re-sent stay byte-for-byte original.
207
+
208
+ Everything is standard library: `http.server`, `sqlite3`, `urllib`, `argparse`, `difflib`, `json`, `shlex`.
209
+
210
+ ---
211
+
212
+ ## Development
213
+
214
+ ```bash
215
+ python -m venv .venv && source .venv/bin/activate
216
+ pip install -e '.[dev]'
217
+ pytest
218
+ ```
219
+
220
+ The test suite (153 tests) is self-contained: it spins real capture and receiver servers on OS-assigned free ports and exercises capture (including chunked bodies and malformed framing), persistence, concurrent writes, the size / retention / timeout limits, replay (success, non-2xx, connection error, stalled target, method/header overrides, verbatim forwarding of sensitive headers), every CLI command, output redaction, `curl` export and JSON diffing end-to-end. No network access or external services required.
221
+
222
+ ```console
223
+ $ pytest
224
+ ........................................................................ [ 47%]
225
+ ........................................................................ [ 94%]
226
+ ......... [100%]
227
+ 153 passed in 22.55s
228
+ ```
229
+
230
+ ---
231
+
232
+ ## Part of a family of small developer tools
233
+
234
+ `webhook-replay` is one of a family of small, dependency-light developer tools I build and maintain in the open — each a focused, standalone utility meant to do one job well. If this one was useful, these siblings might be too:
235
+
236
+ - [The GEO Handbook](https://github.com/ferinazumaDEV/generative-engine-optimization-handbook) — the open reference on getting content cited by AI answer engines (ChatGPT, Perplexity, Google AI Overviews, Gemini, Copilot).
237
+ - [politeclient](https://github.com/ferinazumaDEV/politeclient) — a polite, bulletproof HTTP client for Python: retries with backoff, per-host rate-limiting, caching and pagination.
238
+ - [scaffld](https://github.com/ferinazumaDEV/scaffld) — scaffold fully-wired Python projects (tests, CI, pre-commit, license) from templates, with a TUI.
239
+ - [typedout](https://github.com/ferinazumaDEV/typedout) — reliable structured output from any LLM: schema-validated JSON with tolerant repair and retries.
240
+ - Hub & writing: [zentimes.es](https://zentimes.es).
241
+
242
+ By [ferinazumaDEV](https://github.com/ferinazumaDEV).
243
+
244
+ ---
245
+
246
+ ## License
247
+
248
+ MIT — see [LICENSE](LICENSE).
249
+
250
+ ---
251
+
252
+ _Built by Fernando Aporta Franco ([@ferinazumaDEV](https://github.com/ferinazumaDEV))._
@@ -0,0 +1,43 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "webhook-replay"
7
+ version = "0.1.0"
8
+ description = "Capture, inspect and re-fire webhooks locally — a zero-dependency dev tool for debugging webhook integrations."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Fernando Aporta Franco", email = "ferinazumaDEV@users.noreply.github.com" }]
13
+ keywords = ["webhook", "http", "debugging", "developer-tools", "replay", "cli"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Software Development :: Debuggers",
21
+ "Topic :: Internet :: WWW/HTTP",
22
+ ]
23
+ # Runtime deps: none. Everything runs on the standard library.
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ dev = ["pytest>=7.0"]
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/ferinazumaDEV/webhook-replay"
31
+ Source = "https://github.com/ferinazumaDEV/webhook-replay"
32
+
33
+ [project.scripts]
34
+ webhook-replay = "webhook_replay.cli:main"
35
+
36
+ [tool.setuptools.packages.find]
37
+ include = ["webhook_replay*"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+ addopts = "-q"
42
+ # Make `tests.conftest` importable under plain `pytest` (not only `python -m pytest`).
43
+ pythonpath = ["."]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+