Testing an inbound webhook is annoying in a way that's out of proportion to the problem.

A provider — Stripe, GitHub, Shopify, whatever — will POST a JSON body to a URL you own. You need to see exactly what they send, and you need to send it again after you change your handler. Neither of those is hard. But the usual setup is:

Start a tunnel so the provider can reach your laptop
Paste the generated URL into the provider's dashboard
Trigger the event in their UI
Read your application logs and hope you logged enough
Change your handler
Go back to step 3, because you can't replay what already arrived

Step 6 is the expensive one. Re-triggering a real event through a third party's UI to test a one-line change is a slow loop, and some events aren't easy to trigger on demand at all.

The part you actually need

Strip it down and there are only three requirements:

Capture whatever arrives: method, path, headers, body — unaltered
Inspect it, including the headers, which is usually where the signature lives
Replay it at your handler as many times as you like

None of that requires a tunnel. A tunnel solves a different problem — reachability from the public internet — and once you've captured a payload once, you don't need the provider involved again.

Doing it with the standard library

Python's http.server is enough. A catch-all handler that records the request and returns 200:

from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

CAPTURED = []

class Handler(BaseHTTPRequestHandler):
    def _capture(self):
        length = int(self.headers.get("Content-Length", 0))
        CAPTURED.append({
            "method": self.command,
            "path": self.path,
            "headers": dict(self.headers),
            "body": self.rfile.read(length).decode("utf-8", "replace"),
        })
        self.send_response(200)
        self.send_header("Content-Type", "application/json")
        self.end_headers()
        self.wfile.write(b'{"status":"captured"}')

    do_POST = do_PUT = do_PATCH = do_GET = do_DELETE = _capture

    def log_message(self, *args):
        pass

ThreadingHTTPServer(("127.0.0.1", 8000), Handler).serve_forever()

Enter fullscreen mode Exit fullscreen mode

Point anything at http://127.0.0.1:8000/hook/test and it's recorded.

Replay is just as plain — take a captured request and send it somewhere else:

import json, urllib.request

def replay(captured, target_url):
    req = urllib.request.Request(
        target_url,
        data=captured["body"].encode(),
        headers={k: v for k, v in captured["headers"].items()
                 if k.lower() not in ("host", "content-length")},
        method=captured["method"],
    )
    with urllib.request.urlopen(req, timeout=10) as resp:
        return resp.status, resp.read().decode()

Enter fullscreen mode Exit fullscreen mode

Dropping Host and Content-Length matters — both refer to the original request and will either be wrong or fight with what urllib sets itself.

That's the whole idea. Everything after this point is convenience: storing more than one request, a UI to read them, matching by endpoint, persisting across restarts.

Things that bite you

Signature verification will fail on replay. Providers sign the raw body, often with a timestamp to prevent exactly this kind of replay. If your handler verifies signatures, you'll need a way to skip verification in development. That's a property of the signature scheme, not something a capture tool can fix.

Read the body exactly once. self.rfile.read() consumes the stream. Read it into a variable first and work from that.

Don't decode blindly. Not every webhook body is UTF-8 JSON. errors="replace" keeps a binary payload from taking down your handler.

Bind to 127.0.0.1. The default in a lot of example code is 0.0.0.0, which puts an unauthenticated endpoint that logs full request bodies — including auth headers — on every interface your machine has.

If you'd rather not maintain it

I got tired of pasting this into projects, so I built it properly and sell it as HookTrap — $49, one-time, source included, no subscription.

It's the same idea with the sharp edges filed off: captures any method to /hook/{endpoint_id}, a built-in web UI at / for reading requests and firing replays, a JSON API (GET /api/requests, POST /api/replay/{id}, DELETE /api/requests), and optional persistence with --file storage.json. Still Python 3.9+ standard library only — no pip install, no account, nothing leaves your machine.

Things it deliberately does not do: no HTTPS of its own (run it behind a proxy if you need TLS), no authentication on the UI, and replays run synchronously so a slow target blocks the call. Those are in the README before you buy, not after.

HookTrap — $49, self-hosted

Disclosure: I built HookTrap and it's a paid product. The code in this post is standard library only and yours to use — the article works fine without buying anything.