Webhook observability — inspect · replay · forward · retain § for engineers who debug in production. Yes, we said it.

The quiet
discipline of
listening to
your webhooks.

Figure 1 — The Inspector, as it arrived from Stripe on Tuesday afternoon req_01HQZ4K9 · 14:32:08 · 142ms
The Record n = 8
200 POST /hooks/stripe 142ms
from stripe · 14:32:08
200 POST /hooks/stripe 98ms
from stripe · 14:32:08
500 POST /hooks/github 3.4s
from github · 14:31:52
200 POST /hooks/linear 62ms
from linear · 14:31:41
200 POST /hooks/shopify 201ms
from shopify · 14:31:22
429 POST /hooks/twilio 44ms
from twilio · 14:30:59
200 POST /hooks/stripe 110ms
from stripe · 14:30:41
200 POST /hooks/slack 88ms
from slack · 14:30:12
invoice.paid 200 OK from stripe.com · forwarded to api.acme.io · mirrored to s3://acme-hooks
Headers
content-type      application/json; charset=utf-8
user-agent        Stripe/1.0 (+https://stripe.com/docs/webhooks)
stripe-signature  t=1745345528,v1=3a9bf41cd9…
x-forwarded-for   3.18.12.63
Body
{
  "id": "evt_3PqXf2A7BkN",
  "type": "invoice.paid",
  "created": 1745345528,
  "data": {
    "object": {
      "amount_paid": 2400,
      "currency": "usd",
      "customer": "cus_QmVb3LN4P"
    }
  }
}
The Four Verbs more than a tunnel — more than a request bin
I.

Inspect

Every request, headers and all. Decoded bodies, verified signatures, full timing. Never truncated, never summarized.

$ notunnel inspect req_01HQZ4K9
II.

Catch

One durable inbox for every webhook from every source. Stripe, GitHub, Shopify, Twilio, your own services — all in one record.

POST acme.notunnel.sh/hooks/stripe
III.

Replay

Resend any request — or a filtered range — to localhost, staging, or anywhere. Rewrite the body in flight: swap prod ids, override keys, fix the signature.

✦ the part nobody else does well $ notunnel replay req_01HQZ4K9 --to :3000
IV.

Retain

Ninety days of full-text searchable history. Export to S3 — ours or yours. On Publisher, your payloads never leave your infrastructure.

$ notunnel search --since 30d invoice.paid
An aside on the name

Why not just tunnel?

Tunnels forward one webhook to one laptop, and only while that laptop is awake, on that network, running that process. Miss the window, miss the event. We keep the webhook.

Figure 2 — Replay, rewritten in flight the part tunnels cannot do
caught req_01HQZ4K9 · from stripe
{
  "type": "invoice.paid",
  "data": {
    "object": {
      "customer": "cus_QmVb3LN4P",
      "amount_paid": 2400,
      "status": "paid"
    }
  }
}
transform notunnel cli · rewrite in flight
$ notunnel replay req_01HQZ4K9 \
    --to localhost:3000 \
    --set data.object.customer=cus_local_test \
    --set data.object.amount_paid=100 \
    --resign stripe
delivered localhost:3000 · 38ms · 200 OK
{
  "type": "invoice.paid",
  "data": {
    "object": {
      "customer": "cus_local_test",
      "amount_paid": 100,
      "status": "paid"
    }
  }
}
// stripe-signature re-signed with your local webhook secret

Swap prod ids for local fixtures, drop amounts to test edge cases, re-sign with your local webhook secret — all without touching the recorded event. The receipt stays immutable. The replay is whatever you need it to be.

While we build it

The inbox
is open.
The product
isn't — yet.

Put your name on the list. We'll webhook your inbox when we ship — and your signup will already be waiting in your notunnel inbox the moment you sign in.

or, to stay on brand
$ curl https://notunnel.sh/waitlist \
     -H 'content-type: application/json' \
     -d '{"email":"you@company.com"}'
Response · application/json
{
  "event": "waitlist.joined",
  "email": "you@company.com",
  "inbox": "b3f-waitlist.notunnel.sh",
  "message": "We'll webhook your inbox when we ship."
}
Figure 3 — At the Command Line

Filter like grep,
replay like git.

The CLI treats your inbox like a log file that knows JSON. Filter by source, status, event, time, or a jq-style predicate on the body. Replay a single request or a whole range — the same grammar works either way.

~/acme $ notunnel tail --source stripe --status 2xx --since 1h
→ listening to acme-prod · 3 filters

14:32:08.412  200  POST /hooks/stripe   invoice.paid      142ms
14:32:08.104  200  POST /hooks/stripe   charge.succeeded   98ms
14:28:41.003  200  POST /hooks/stripe   customer.updated   61ms

────────────────────────────────────────────

~/acme $ # bulk replay: every failed stripe webhook, last 2h, to local
~/acme $ notunnel replay --source stripe --status 5xx --since 2h \
      --to localhost:3000 --resign stripe

→ replaying 7 requests to localhost:3000
   req_01HQZ4K9  invoice.paid             200 · 38ms
   req_01HQZ50W  invoice.payment_failed   200 · 41ms
   req_01HQZ512  charge.refunded          200 · 29ms
  ...

────────────────────────────────────────────

~/acme $ # filter on the body itself — jq-style predicate
~/acme $ notunnel search --body '.data.object.amount_paid > 10000' \
      --since 24h

→ 3 matching requests
  req_01HQZ4K9  invoice.paid          $120.00
  req_01HQYX4L  invoice.paid          $480.00
  req_01HQYN82  invoice.payment_failed $1,200.00
Figure 4 — The Pricing (draft) flat · fair · unfinalized

Pricing,
before it exists.

No numbers yet — we want to learn what works before we publish them. But we've already decided what pricing will and won't be. Consider this the pre-publication draft, in the shape of the kind of response the product will send.

GET /pricing/v1 206 Partial Content DRAFT
{
  "model": "flat",               // never per-seat
  "free_forever": true,          // generous quota, in perpetuity
  "own_your_logs": true,         // take them with you when you leave

  "per_seat_math": false,
  "per_request_anxiety": false,
  "retention_paywall": false,
  "sales_gate": false,

  "promise": "Whatever the number is, you won't be surprised.",
  "published": "at launch"
}

The finalized version ships — as a webhook, naturally — to everyone on the waitlist the day we launch.