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.
- webhook_replay-0.1.0/LICENSE +21 -0
- webhook_replay-0.1.0/PKG-INFO +275 -0
- webhook_replay-0.1.0/README.md +252 -0
- webhook_replay-0.1.0/pyproject.toml +43 -0
- webhook_replay-0.1.0/setup.cfg +4 -0
- webhook_replay-0.1.0/tests/test_cli.py +522 -0
- webhook_replay-0.1.0/tests/test_export_and_diff.py +131 -0
- webhook_replay-0.1.0/tests/test_redact.py +94 -0
- webhook_replay-0.1.0/tests/test_replay.py +173 -0
- webhook_replay-0.1.0/tests/test_server.py +501 -0
- webhook_replay-0.1.0/tests/test_storage.py +132 -0
- webhook_replay-0.1.0/webhook_replay/__init__.py +29 -0
- webhook_replay-0.1.0/webhook_replay/__main__.py +9 -0
- webhook_replay-0.1.0/webhook_replay/_color.py +60 -0
- webhook_replay-0.1.0/webhook_replay/cli.py +487 -0
- webhook_replay-0.1.0/webhook_replay/diff.py +60 -0
- webhook_replay-0.1.0/webhook_replay/export.py +45 -0
- webhook_replay-0.1.0/webhook_replay/redact.py +83 -0
- webhook_replay-0.1.0/webhook_replay/replay.py +117 -0
- webhook_replay-0.1.0/webhook_replay/server.py +326 -0
- webhook_replay-0.1.0/webhook_replay/storage.py +217 -0
- webhook_replay-0.1.0/webhook_replay.egg-info/PKG-INFO +275 -0
- webhook_replay-0.1.0/webhook_replay.egg-info/SOURCES.txt +25 -0
- webhook_replay-0.1.0/webhook_replay.egg-info/dependency_links.txt +1 -0
- webhook_replay-0.1.0/webhook_replay.egg-info/entry_points.txt +2 -0
- webhook_replay-0.1.0/webhook_replay.egg-info/requires.txt +3 -0
- webhook_replay-0.1.0/webhook_replay.egg-info/top_level.txt +1 -0
|
@@ -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
|
+

|
|
27
|
+

|
|
28
|
+

|
|
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
|
+

|
|
4
|
+

|
|
5
|
+

|
|
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 = ["."]
|