Skip to the tool
MoveAheadPayments Toolbox

How to receive webhooks on localhost without a tunnel

A tunnel points the internet at your laptop and loses everything sent after you close it. Capturing first and forwarding outward keeps the deliveries, replays them on demand, and never exposes a port.

You are integrating a payment gateway. Your handler runs on http://localhost:3000/webhooks. The gateway's dashboard wants a public HTTPS URL, and there is no arrangement of words that will make it accept yours.

This is not a configuration problem. The gateway resolves and connects to your URL from its own servers, where localhost means the gateway's machine. The same is true of 192.168.x.x and 10.x.x.x: private addresses are not routable from the internet, by design. Something has to bridge the gap.

The two shapes of answer

Either you make your machine reachable from the internet, or you receive the delivery somewhere that already is and move it to your machine afterwards. Tunnels do the first. They work, and for webhook development specifically they have three problems.

A tunnel is only alive while you are. Close the laptop and the URL stops answering. Whatever the gateway sends next is not queued anywhere — it fails, the gateway retries on its own schedule, and gateways disable endpoints that keep failing. It is genuinely possible to break a sandbox integration by going to lunch.

The URL changes. Unless you are paying for a reserved subdomain, every restart hands you a new hostname and you go back to the gateway's dashboard to re-enter it. That is a small tax that you pay every single day.

It exposes a port on your machine to the public internet. Everything you are running on that port, including whatever debug endpoints your framework mounts in development. Tunnel services index nothing, but the addresses are guessable and scanned.

Inverting the direction

The alternative is to capture first. A public endpoint receives the delivery and stores it — bytes, headers, method, exactly as they arrived — and a small program on your machine collects them and posts them at your local service. Traffic flows outward from your machine in both directions, so nothing of yours is reachable from outside, and the endpoint keeps accepting deliveries whether or not anything is listening.

One command, no account, no tunnel
npx pymnt-forward fwd_YOUR_TOKEN \
  --to http://localhost:3000/webhooks

Get a URL and a token from the free webhook test endpoint — it takes an email and nothing else. Point your gateway at the URL, run the command, and deliveries appear at your handler as they arrive.

Why “verbatim” is the whole game

A forwarder that reformats anything is worse than useless, because a webhook signature is an HMAC over the exact bytes the gateway sent. Here is the same event twice — as it arrived, and as a JSON middleware would hand it to your handler after parsing and re-serialising it:

As sent, and its signature
{"event":"payment.captured","payload":{"amount":49900}}

bc0c2053f2a178630de1f569b1ef0674de3694b4b046f260ec34054eeeb5d107
Re-serialised — same data, different bytes, different digest
{
  "event": "payment.captured",
  "payload": {
    "amount": 49900
  }
}

5236af7478781976820004d4fffca276bb6e8e25bbf6cf346b652e07b0c60239

Nothing was added or removed. The keys are in the same order and the amount is the same number. Only the whitespace differs, and the digest shares not one character with the first. This is the single most common cause of a signature that will not verify, and it is why the forwarder sends stored bytes rather than anything it has parsed.

The method travels too: a gateway that delivers with PUT is forwarded as PUT. So does the sender's header set — including the signature header your code is about to check.

What cannot be forwarded, and why

Two categories are dropped, and both would produce a request that is wrong on the wire rather than faithful:

  • Connection headers. host and content-length describe the hop that has just finished. A stale content-length truncates the body at your service, and a host naming the capture endpoint would be sent to yours.
  • Infrastructure headers. Anything the capture endpoint's own host attached on the way in — proxy and routing headers. They are the capture service's account of the request, not the gateway's, and forwarding them would misreport where the delivery came from.

Everything the sender actually set survives: content-type, x-razorpay-signature, user-agent, and any other header your gateway sends. The rule is applied where the delivery is stored rather than in the forwarder, so the list of what is yours and what is ours has exactly one definition.

When a tunnel is still the right tool

Webhooks are one-way notifications, which is what makes capture-and-forward work for them. If you need the internet to reach your machine for something interactive — an OAuth redirect landing back on your dev server, a hosted checkout iframe loading a local asset, a colleague opening your work-in-progress — a tunnel is doing something this cannot, and you should use one.

The distinction is whether anything needs a response from your machine in real time. A webhook does not: the gateway wants a 200, and it can have one from the capture endpoint immediately.

The limits worth knowing

  • A free test endpoint lasts 15 minutes and holds 20 deliveries. That is sized for confirming your integration works, not for a day of debugging.
  • Deliveries are stored on a server for that period, so a test endpoint should be treated as a public place. Do not point live customer traffic at one.
  • Your signing secret is never involved in any of this. Verification happens in your browser or in your own code — the capture endpoint holds payloads, not secrets.

If the forwarded delivery fails your signature check, the problem is in your verification code rather than in the transport, and the signature verifier will show you which exact string should have been signed.

Check it against the tool

  • Free webhook testing endpoint

    Get a URL that catches webhooks for fifteen minutes: see the exact bytes that arrived, check the signature against your secret in your browser, and forward every delivery to localhost with one command — no tunnel.

  • Webhook signature verifier

    Check a Razorpay, Stripe, Cashfree or PayU signature against your secret — webhooks, and Razorpay's checkout and payment-link schemes — and see exactly which string was signed.

Frequently asked questions

Can a payment gateway send a webhook to localhost?
No. A gateway resolves and connects to your URL from its own servers, and localhost there means the gateway's own machine, not yours. The same is true of any private address — 192.168.x.x and 10.x.x.x are not reachable from the internet either. Something has to bridge the gap: either you expose your machine with a tunnel, or you receive the delivery somewhere public and move it to your machine afterwards.
Is this the same as ngrok?
No, and the direction of travel is the difference. A tunnel makes your machine reachable from the internet, so traffic flows inward and everything stops the moment you close the tunnel — anything the gateway sends in the meantime is gone, and gateways disable endpoints that keep failing. Capturing first inverts that: deliveries are stored whether or not you are listening, the forwarder pulls them outward to your machine, and nothing on your machine is ever reachable from outside. The cost is that the public URL belongs to the capture service rather than to you.
Will my signature verification still pass on a forwarded delivery?
It should, and that is the point. The forwarder sends the stored bytes unchanged, with the original method and the sender's headers, so the HMAC your code computes is over exactly what the gateway signed. If verification fails on a forwarded delivery, that is a real finding about your verification code rather than an artefact of forwarding — which is precisely why it is worth checking this way before you go looking in your own middleware.
What happens to deliveries that arrive while the forwarder is not running?
They are captured and wait for you. Start the forwarder with --from-start and it replays everything the endpoint is holding, oldest first. This is the practical advantage over a tunnel: you can close your laptop, and the webhook your gateway sent at lunchtime is still there to be replayed against your service in the afternoon.