# create a bin (plain text, one command) curl https://catchhook.catchhook.workers.dev/new # or JSON curl -s -X POST https://catchhook.catchhook.workers.dev/api/bins # send anything to the capture URL curl -X POST https://catchhook.catchhook.workers.dev/h/BIN_ID/any/path?x=1 -d 'payload' # list captured requests (JSON) curl -s https://catchhook.catchhook.workers.dev/api/bins/BIN_ID/requests
A tiny shell client (curl + sed, ~50 lines, no account needed) lives at https://catchhook.catchhook.workers.dev/cli:
# install curl -s https://catchhook.catchhook.workers.dev/cli -o catchhook && chmod +x catchhook # create a bin ./catchhook new # live-tail captures into your terminal (Ctrl-C to stop) ./catchhook tail BIN_ID # also accepts the full /h/ or /b/ URL # re-deliver captures to a server on YOUR machine (Ctrl-C to stop) ./catchhook relay BIN_ID http://localhost:3000 # new captures only ./catchhook relay BIN_ID http://localhost:3000 --all # history too
Each capture is printed with method, path, signature badge (if signature verification is on), headers, and a pretty-printed body. Under the hood it polls GET https://catchhook.catchhook.workers.dev/api/bins/BIN_ID/tail?after=N, a plain-text endpoint you can also script against directly — the x-catchhook-last response header carries the cursor for the next poll.
./catchhook relay turns a bin into a bridge to your dev machine: point the webhook provider at https://catchhook.catchhook.workers.dev/h/BIN_ID, run the relay, and every capture is re-delivered to your local server with the original method, sub-path, query string, headers (host/proxy headers stripped) and byte-identical body — so HMAC signatures like GitHub's X-Hub-Signature-256 still verify against the relayed request. No tunnel daemon, no firewall holes, no ngrok account: it's outbound polling with plain curl, so it works behind NAT, corporate proxies, and CI runners. Captures also stay in the dashboard, so you get inspection + local delivery at the same time. See the step-by-step guide.
Your bin's capture URL is https://catchhook.catchhook.workers.dev/h/BIN_ID. Any method, any sub-path, any query string is captured: GET, POST, PUT, PATCH, DELETE, HEAD, OPTIONS. Bodies are stored up to 100 KB (larger bodies are truncated). JSON bodies are pretty-printed in the dashboard, and application/x-www-form-urlencoded bodies (Slack, Twilio, GitHub's form mode…) are decoded into readable key = value lines. Binary bodies (protobuf, gzip, file uploads…) are kept byte-exact — the dashboard shows a hex dump with a "download raw bytes" link, and replay, CLI relay, and exports all resend the original bytes, so HMAC signatures still verify downstream. CORS is open on capture endpoints, so browser apps can hit them too.
Signed-in users can give any bin a memorable alias in its settings: set a slug like my-stripe-dev and the bin answers on https://catchhook.catchhook.workers.dev/h/my-stripe-dev (the dashboard moves to /b/my-stripe-dev too). The original random URL keeps working. Slugs are 3–40 chars of lowercase letters, digits, and hyphens, unique across CatchHook — first come, first served. Handy when you re-configure the same provider often, or want a URL you can remember and type.
By default a bin answers 200 with {"ok":true}. In the bin dashboard you can set any status code (e.g. 500 to test your retry logic), content type, body, and a response delay of up to 10 seconds — handy for testing client timeouts and retry/backoff behaviour.
The response body supports {{variable}} placeholders that are filled in from each incoming request. This is how you pass provider URL-verification handshakes that require echoing a challenge — e.g. set the body to:
{"challenge":"{{body.challenge}}"}
and your bin passes Slack's (and Zoom's, Dropbox's…) endpoint verification while still capturing every event. Available variables:
| Variable | Value |
|---|---|
{{body}} | the raw request body (full echo) |
{{body.a.b}} | field from a JSON body (dot-path into nested objects) or a form-urlencoded body |
{{query.name}} | a query-string parameter ({{query}} alone = the whole query string) |
{{header.x-foo}} | a request header (case-insensitive) |
{{method}} / {{path}} | HTTP method / sub-path under the bin URL |
{{ip}} / {{timestamp}} / {{bin}} | caller IP / current Unix time (seconds) / bin ID |
Unknown or missing variables render as an empty string. Values are substituted literally (no quoting added) — challenge tokens are alphanumeric so this is safe for the handshake use case. A body without {{ is returned exactly as-is, byte for byte.
Debugging "invalid signature" errors is the worst part of webhook work. Set a signing secret in the bin's settings and CatchHook verifies every incoming request's HMAC, showing a ✓ or ✗ badge per request. Supported schemes:
| Scheme | Header checked | How it's verified |
|---|---|---|
| GitHub | X-Hub-Signature-256 | sha256= + hex HMAC-SHA256 of the raw body |
| Stripe | Stripe-Signature | v1 = hex HMAC-SHA256 of {t}.{body} (timestamp tolerance not enforced — this is a debugging tool) |
| Generic HMAC | any header you name | hex or base64 HMAC-SHA256 of the raw body, optional sha256= prefix |
Verification runs on the full raw body, even when the stored copy is truncated. Many providers (Shopify, Slack-adjacent tools, most "webhook secret" implementations) use one of these three shapes.
Captured a webhook you wish had hit your real endpoint? Every request has a Replay ▸ button (bin owner only): give it a target URL and CatchHook re-sends the exact method, headers, and body to it, then shows you the status code and latency your endpoint returned. The replayed request carries an X-CatchHook-Replay: 1 header so you can tell it apart on your side.
Targets must be public http(s) hosts — for localhost, use a tunnel (e.g. cloudflared tunnel --url localhost:3000). Replays time out after 10s. Or use "Copy as" (curl / fetch / Python / HTTPie) and run it yourself.
Don't want to take your webhook offline while you debug? Set a Forward to URL in the bin's settings and every captured request is also mirrored to your real endpoint — same method, headers, and body, with the sub-path and query string appended to your URL. Point your provider at CatchHook, keep production flowing, and watch everything in the live dashboard at the same time.
Forwarded requests carry X-CatchHook-Forwarded: 1 and X-CatchHook-Bin headers. Forwarding is fire-and-forget (your webhook sender always gets the bin's configured response) and times out after 10s. Same public-host rule as Replay.
Every capture has a Copy as menu: get the exact request as a ready-to-run curl command, a JavaScript fetch() call, a Python requests snippet, or an HTTPie command — headers and raw body included (hop-by-hop and CF headers stripped). Paste a real webhook straight into your test suite.
Why did yesterday's delivery work and today's fail? Hit Diff… on any two captures in the dashboard and get a colorized, line-by-line diff of the request line, headers, and (pretty-printed) body — long unchanged stretches are collapsed so the change jumps out. Works for everyone viewing the bin, no account needed.
| Anonymous | Free account | |
|---|---|---|
| Requests kept per bin | 100 | 1,000 |
| Retention | 24 hours | 30 days |
| Bins | unlimited | unlimited |
| Ingest rate per bin | 120/minute | 300/minute |
Over the ingest rate, the bin answers 429 with a Retry-After header until the minute window clears. New-bin creation is capped at 30/hour per IP.
When a bin is full, oldest requests are dropped first. Need more? Tell me — limits are soft while CatchHook grows.
Heads-up: Cloudflare's edge (not CatchHook) answers 403 error code: 1010 to Python's default Python-urllib user-agent. If you test with urllib, set any User-Agent header — requests, curl, Go, Java, Ruby and every real webhook sender work as-is.
GET https://catchhook.catchhook.workers.dev/new # create bin (plain-text answer)
POST https://catchhook.catchhook.workers.dev/api/bins # create bin → {bin,url,inspect}
GET https://catchhook.catchhook.workers.dev/api/bins/:id/requests # list requests (?after=ID for polling)
DELETE https://catchhook.catchhook.workers.dev/api/bins/:id/requests # clear captured requests
GET https://catchhook.catchhook.workers.dev/api/bins/:id/export # download all captures as JSON
GET https://catchhook.catchhook.workers.dev/api/bins/:id/har # download as HAR 1.2 (Chrome DevTools, Insomnia…)
DELETE https://catchhook.catchhook.workers.dev/api/bins/:id # owner; delete the bin entirely
POST https://catchhook.catchhook.workers.dev/api/bins/:id/requests/:rid/replay # owner; body {"target":"https://…"}
GET https://catchhook.catchhook.workers.dev/api/bins/:id/requests/:rid/share # mint read-only share link → {url}
Every capture has a Share link button: it copies a read-only permalink (https://catchhook.catchhook.workers.dev/r/…) to just that one request — headers, body, signature badge. Paste it in Slack, a GitHub issue, or a bug report. The link shows only that capture: it never exposes your bin, its URL, or other requests, and it can't be guessed (HMAC-signed). It works as long as the capture is retained (24h anonymous / 30 days with an account).