DEV Community

Lightning Developer
Lightning Developer

Posted on

Webhook Testing on Localhost: Tunnels, Signatures, and Reliable Replays

When a service wants to tell your application about an event, it does not wait for you to ask. It sends an HTTP POST to a URL that you registered. A payment clears, a pull request opens, an order ships, and the provider calls your endpoint. This pattern is called a webhook, and it is everywhere.

The problem is that the URL has to be reachable from the public internet. During development, your application runs on localhost:3000, behind a home router or an office NAT. GitHub, Stripe, and Slack cannot reach that address. To test webhooks locally, you need two things: a temporary public URL that forwards to your local port, and a handler that only trusts requests that were genuinely signed by the provider.

This guide follows one example from start to finish. You will receive a GitHub repository webhook in an Express handler on port 3000 through a Pinggy tunnel. Stripe, Slack, and Shopify work the same way, with only the signature details changing. The code and tests in this article were run on Node.js 25.5 with Express 5.3.0 in October 2026.

Key points

  • One command gives localhost a public URL. ssh -p 443 -R0:localhost:3000 free.pinggy.io prints an HTTPS address that forwards to port 3000. Free tunnels last 60 minutes and get a new random URL each time. A Pro token keeps one subdomain.
  • Verify the signature on the raw bytes. GitHub signs the exact request body with HMAC-SHA256 and sends it in X-Hub-Signature-256. Read the body with express.raw(), never express.json(), and compare with crypto.timingSafeEqual.
  • Reply fast and expect repeats. GitHub waits 10 seconds and never retries automatically. Shopify waits 5 seconds and retries 8 times over 4 hours. Return a 2XX first, queue the work, and skip delivery IDs you have already handled.
  • Replay instead of re-triggering. Pinggy Web Debugger, started with -L4300:localhost:4300, shows every delivery and can resend it as-is or with edited headers.
  • Skip the tunnel when the provider ships a CLI. stripe listen and gh webhook forward deliver events to localhost without a public URL.

How a webhook reaches localhost:3000

Nothing on your router changes. Your laptop makes the first connection outward. An SSH client dials Pinggy on port 443 and asks it to forward a public URL back down that connection. When GitHub POSTs to the URL, the request follows a path: GitHub to Pinggy, then through the SSH connection your laptop opened, then to localhost:3000. The handler's response travels back along the same path.

Because the laptop starts the connection, this approach also works behind CGNAT. With CGNAT, your router does not have a public IP address, so port forwarding cannot work from the outside. A reverse tunnel does not care. It only needs an outbound connection from your machine to the tunnel server.

Free Pinggy URLs show a one-time screening page, but only to browsers. Webhook senders, curl, and HTTP libraries go straight through to your handler. That means a tunnel is safe to use for webhook testing as long as you enforce a signature check.

Blog Image

Why signature verification is non-negotiable

A public tunnel URL is guessable. It may be a random subdomain, but attackers scan subdomains and IP ranges all the time. If your endpoint accepts any request without verifying a signature, anyone can send a fake event. They could trigger a build, mark an order as paid, or spam your logs. Signature verification is the only reliable way to know that a request came from the provider.

Providers do not all use the same cryptographic primitive, but the pattern is similar. They compute a digest of the request body and a secret, put the digest in a header, and send the request. Your handler recomputes the digest and compares it with the header. If they match, you can trust the body. If they do not, reject the request with 401 Unauthorized.

This is not a theoretical concern. When you open a tunnel, your local endpoint is on the public internet. Attackers can hit it within minutes. A signature check costs a few lines of code and saves you from a long debugging session.

Understanding the signature header

The X-Hub-Signature-256 header is not just a hex string. It is prefixed with sha256=. Some older integrations use X-Hub-Signature, which is the SHA-1 version. GitHub still accepts both for compatibility, but its documentation recommends the SHA-256 header for new integrations. If you only check the legacy header, a delivery signed with SHA-256 would fail. If you only check the SHA-256 header, old tooling will be rejected. For a fresh webhook, you should choose X-Hub-Signature-256 and ignore the legacy header.

When you construct the expected value, use the same prefix. The code does 'sha256=' + ..., which matches what GitHub sends. Some developers forget the prefix and then wonder why every request returns 401. The header also uses lowercase hex characters. You do not need to normalize case if you compute the digest with digest('hex'), because Node.js returns lowercase. If you use uppercase, you would need to lowercase before comparison.

Trust boundaries and tunnels

Even with a signature check, the tunnel is part of your trust boundary. Anyone who can reach the tunnel URL can send requests to your handler. They will fail the signature check, but the request still reaches your Node.js process. This means you should not rely on the signature check alone to protect against denial-of-service. A malformed request without an X-Hub-Signature-256 header is rejected after a quick length check, but parsing the body and logging it costs CPU and memory.

Pinggy free tunnels include a one-time browser warning page. This is not a security mechanism for webhooks. It only exists to stop a random person from opening the URL in a browser and seeing your local app. Automated clients, including webhook providers and attackers, skip it. Keep your handler behind a signature check even if you are only testing.

Source IP restrictions are tempting, but unreliable for GitHub because its IP ranges change. For Stripe, the published IP ranges are more stable, and you could combine an allowlist with the signature. Defense in depth is nice, but the signature is the primary control.

Step 1: Write a handler that checks the signature

GitHub computes an HMAC-SHA256 of the request body using a secret you choose. It sends the hex digest in the X-Hub-Signature-256 header, prefixed with sha256=. Your handler recomputes that value and compares the two.

Set up a folder and generate a secret:

mkdir webhook-demo && cd webhook-demo
npm init -y && npm install express
openssl rand -hex 32
Enter fullscreen mode Exit fullscreen mode

The command uses OpenSSL to generate a random 64-character hex string. Copy the output. GitHub will need the same value later. Save the handler as server.mjs:

import express from 'express';
import crypto from 'node:crypto';

const app = express();
const SECRET = process.env.GITHUB_WEBHOOK_SECRET;
const seen = new Set();

app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
  if (!Buffer.isBuffer(req.body)) {
    return res.status(415).send('expected a raw application/json body');
  }

  const expected =
    'sha256=' + crypto.createHmac('sha256', SECRET).update(req.body).digest('hex');
  const received = req.get('X-Hub-Signature-256') ?? '';

  const a = Buffer.from(expected);
  const b = Buffer.from(received);
  if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
    return res.status(401).send('bad signature');
  }

  const delivery = req.get('X-GitHub-Delivery');
  if (seen.has(delivery)) return res.sendStatus(200);
  seen.add(delivery);

  const event = req.get('X-GitHub-Event');
  const payload = JSON.parse(req.body.toString('utf8'));
  console.log(event, payload.action ?? '', delivery);

  res.sendStatus(202);
});

app.listen(3000, () => console.log('Listening on http://localhost:3000'));
Enter fullscreen mode Exit fullscreen mode

Start the server with the secret in the environment. The --watch flag restarts it when you edit the file:

GITHUB_WEBHOOK_SECRET='paste-the-secret-here' node --watch server.mjs
Enter fullscreen mode Exit fullscreen mode

Why express.raw() matters

The signature covers the exact bytes GitHub sent. express.json() parses the body into a JavaScript object and then serializes it again when you need the raw string, which can change whitespace, escape characters, or Unicode normalization. The digest from the provider will stop matching. express.raw({ type: 'application/json' }) gives you the body as a Buffer before any parsing. You verify the signature on those raw bytes and only then parse the JSON.

The length check before crypto.timingSafeEqual is also important. Node.js throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH when the two buffers differ in size, which happens when the header is missing. Comparing lengths first avoids the exception and avoids leaking the expected length through timing.

Blog Image

Duplicate deliveries

A redelivery keeps the same X-GitHub-Delivery header. GitHub's documentation states that if you request a redelivery, the delivery ID stays the same. The handler uses an in-memory Set to skip repeats. This is perfect for a demo. It is wrong for production, because it forgets everything on restart and does not work across multiple instances.

In a real system, insert the delivery ID into a table with a unique index in the same transaction as the work. If a duplicate arrives, the insert fails with a unique constraint violation and you know the event was already handled. This is the same idempotency strategy used by payment providers and other webhook-heavy services.

The reply window

GitHub waits 10 seconds for a 2XX response. If the handler takes longer, GitHub cuts the connection and records the delivery as failed. That is why the handler responds with 202 Accepted as early as possible. Slow operations, such as sending emails or updating a search index, should go to a queue. The code above only logs, but in a real application you would enqueue a background job before returning the response.

Test the handler locally before GitHub

You can sign a request yourself with openssl and send it to the handler. This tests the exact same code path that GitHub will use, without involving GitHub.

BODY='{"zen":"Design for failure.","hook_id":123}'
SIG="sha256=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$GITHUB_WEBHOOK_SECRET" | awk '{print $NF}')"

curl -i http://localhost:3000/webhook \
  -H 'Content-Type: application/json' \
  -H 'X-GitHub-Event: ping' \
  -H 'X-GitHub-Delivery: test-1' \
  -H "X-Hub-Signature-256: $SIG" \
  --data "$BODY"
Enter fullscreen mode Exit fullscreen mode

Run this in a terminal where GITHUB_WEBHOOK_SECRET is set to the same value. The first call returns 202 Accepted. Calling the same command again returns 200 OK because the delivery ID repeats. If you change one character in BODY after computing SIG, the handler returns 401 Unauthorized. Those three outcomes prove that the handler accepts valid requests, rejects invalid signatures, and deduplicates redeliveries.

Step 2: Open the tunnel

Open a second terminal and point a tunnel at port 3000:

ssh -p 443 -R0:localhost:3000 free.pinggy.io
Enter fullscreen mode Exit fullscreen mode

If SSH asks for a password, press Enter. Pinggy prints the tunnel's public URLs, including an HTTPS address shaped like this:

https://uljtt-30-47-152-61.run.pinggy-free.link
Enter fullscreen mode Exit fullscreen mode

Copy that URL. You can build a custom command on the Pinggy website as well.

Free tunnels last 60 minutes and get a new random subdomain every time they start. After a restart, you must paste the new URL into GitHub again. With a Pinggy Pro token, tunnels have no 60-minute limit and you can assign a persistent subdomain from the dashboard. This is helpful when you need a stable webhook URL for several days of development.

The URL is public while the tunnel is running. The signature check is what keeps out requests that GitHub did not sign. Pinggy can also limit a tunnel to specific source IPs with the w: option, but GitHub advises against using IP allowlists for webhooks because its IP ranges change from time to time. Trust the signature, not the source address.

Choosing a stable tunnel URL

For a long integration project, a rotating free URL becomes annoying. Every time the tunnel restarts, you have to edit the GitHub webhook settings and wait for the next test event. A persistent subdomain removes that churn. Pinggy's Pro tier gives you a fixed URL that remains the same across tunnel restarts, which is much easier to put in your local .env file or your webhook configuration.

There is also a practical benefit for the Web Debugger. If you keep the same tunnel URL, the debugger history accumulates across multiple handler restarts, giving you a record of how the provider's requests evolved. With a new URL each time, you lose that history and have to start over.

Step 3: Point the GitHub webhook at the tunnel

Inside the repository, open Settings, then Webhooks, then Add webhook. Fill in the form:

  • Payload URL: the HTTPS tunnel URL plus /webhook, for example https://uljtt-30-47-152-61.run.pinggy-free.link/webhook.
  • Content type: application/json.
  • Secret: the same value you put in GITHUB_WEBHOOK_SECRET.
  • Events: choose Let me select individual events and tick the events your code handles.

Save the webhook. GitHub sends a ping event immediately. The terminal running node --watch prints ping followed by the delivery ID. The Recent deliveries tab on the GitHub webhook page shows the full request, the response status, and a Redeliver button.

Trigger one of the events you selected, such as pushing a commit if you chose push. You will recieve the real event in the same way as the ping. At this point, the entire loop is working: GitHub sends to Pinggy, Pinggy forwards over SSH to localhost, and your handler verifies and responds.

Step 4: Inspect and replay deliveries with the Web Debugger

GitHub's delivery log only covers GitHub. Pinggy's Web Debugger shows every request that hits the tunnel, regardless of the sender. To enable it, add one extra flag to the tunnel command. Since a restarted free tunnel gets a new URL, update the payload URL in GitHub after restarting:

ssh -p 443 -R0:localhost:3000 -L4300:localhost:4300 free.pinggy.io
Enter fullscreen mode Exit fullscreen mode

Open http://localhost:4300 in a browser. Every request appears live with its headers, body, and your handler's response.

Blog Image

The Web Debugger has two powerful actions. Replay resends a captured delivery exactly as it arrived. This is the quickest way to verify duplicate handling: a replayed GitHub delivery should get 200, not a second 202, as long as the server has not restarted and the in-memory Set is still populated. Modify and Replay lets you change the method, path, HTTP version, or any header before resending. You can, for example, corrupt the X-Hub-Signature-256 header and confirm that the handler answers 401. That gives you a way to test both success and failure paths without waiting for GitHub to send anything new.

Other providers: same handler, different header

The handler structure stays the same across providers. The basic configuraton stays the same. The signature header, the signed string, and the reply window change.

Provider Signature header What gets signed Reply within Retries
GitHub X-Hub-Signature-256, sha256= + hex Raw body 10 seconds None automatic; redeliver from UI or API within 3 days
Stripe Stripe-Signature, t=...,v1=... timestamp.body No documented number, quickly Up to 3 days in live mode, 3 times over a few hours in a sandbox
Slack (Events API) X-Slack-Signature, v0= + hex v0:timestamp:body 3 seconds 3 retries, numbered in X-Slack-Retry-Num
Shopify X-Shopify-Hmac-SHA256, base64 Raw body 5 seconds 8 times over 4 hours

Stripe and Slack mix a timestamp into the signed string. A handler that checks the timestamp can reject a captured request replayed later. Stripe's official libraries allow a 5 minute tolerance by default. Slack's documentation says to ignore any request more than five minutes off your local time.

Use the provider's official SDK when one exists. Stripe's Node.js library has constructEvent, which parses the header, checks the timestamp, and compares signatures in constant time. Writing your own HMAC code for Stripe is more error prone because you also have to validate the timestamp scheme and version. The same advice applies for Slack's signing secret and Shopify's HMAC header.

Raw bodies in every framework

The raw body rule applies no matter which framework you use. It just looks different.

  • In Fastify, register a JSON content-type parser with { parseAs: 'buffer' } so the route receives a Buffer.
  • In a Next.js App Router route handler, await request.text() returns the unparsed body, which is what Next.js's own webhook example uses.
  • In Hono, await c.req.text() gives you the body string before any JSON parsing.

Whatever framework you choose, verify the signature before calling JSON.parse. If you parse first, you might accidentally mutate the data and break the signature, or you might expose yourself to attacks that exploit the parser before authentication.

Retry semantics and backoff

Webhook providers have different retry policies. Some retry with exponential backoff, some retry at fixed intervals, and some do not retry at all. The table above summarizes the most common ones. When a provider does retry, you may see the same event arrive multiple times. Your delivery ID deduplication should use a stable identifier that the provider documents. The raw body is not a good idempotency key because two separate events can have identical bodies.

Webhooks can also arrive out of order. If your handler processes order.shipped before order.paid, your application state may be wrong. Use event timestamps or version fields inside the payload to maintain ordering where it matters. Queue consumers should expect at-least-once delivery, so idempotency is not an option, it is a requirement.

When you don't need a tunnel

Some providers ship a CLI that pushes events directly into localhost over an outbound connection. No public URL is needed.

Stripe's CLI is the most polished example. Install it with one of these commands:

npm i -g @stripe/cli@latest
# or
brew install stripe
Enter fullscreen mode Exit fullscreen mode

Then log in and start forwarding:

stripe login
stripe listen --forward-to localhost:3000/webhook
Enter fullscreen mode Exit fullscreen mode

The CLI prints a whsec_ signing secret that stays the same between restarts. You can set it as STRIPE_WEBHOOK_SECRET in your environment and use stripe trigger checkout.session.completed to fire a test event whenever you want one.

GitHub has a similar extension for the GitHub CLI. Install it with:

gh extension install cli/gh-webhook
Enter fullscreen mode Exit fullscreen mode

Then use gh webhook forward to stream repository or organization events to a local URL. Note that only one person can forward a given repository at a time.

GitHub's documentation also still mentions smee.io, a relay that streams payloads to a client over Server-Sent Events. Its README warns that channels are not authenticated, so anyone with the channel URL can read the payloads. That makes it acceptable for a quick test, but not for sensitive data.

Slack apps can avoid a public URL entirely by using Socket Mode. Events arrive over a WebSocket instead of a Request URL. However, Socket Mode apps are not allowed in the public Slack Marketplace, so this only works for internal apps.

A tunnel stays the fallback for the long tail of providers that have no CLI. There is a separate comparison of provider CLIs versus tunnels for webhook testing, but the pattern is usually the same: if your provider has a CLI, use it; if not, open a tunnel.

Hosted inspectors and production gateways

If you only want to see what a provider sends before writing any code, a hosted inspector is faster than a tunnel or CLI. Webhook.site gives you a URL instantly. Free URLs accept up to 100 requests, expire after 7 days, and anyone who knows the URL's ID can read what was sent. Hookdeck has an inspect mode that requires no account and keeps captured requests for 3 days. RequestBin now redirects to Pipedream, so older guides that mention RequestBin are out of date.

For production, where a missed event can cost money, a receiving gateway is a better choice. Services like Svix, Hookdeck, or Convoy sit in front of your application and handle buffering, retries, and replay. Convoy is self-hostable under the Elastic License 2.0, so it is source-available rather than open source. A gateway can protect you from provider retry storms and gives you an audit log of every delivery.

Production hardening

The demo handler is intentionally small. In production, you should also do the following:

  • Use a database-backed deduplication table instead of a Set.
  • Store the signing secret in a secret manager, not in an environment variable hardcoded in a shell command.
  • Add request timeouts and circuit breakers for outbound calls.
  • Log the delivery ID, event type, and signature status in a structured format.
  • Set up metrics for webhook latency, error rate, and duplicate rate.
  • Run the handler behind a reverse proxy that limits request size and rate limits by source IP.

These practices matter because webhook providers can send bursts of events. A simple controller that handles 1 request per second may fail when a provider sends 100 events after a bulk operation.

Handling large payloads

Some webhook bodies are large. GitHub repository events can include many commits. Shopify product updates can be several megabytes. express.raw() buffers the entire body in memory. For most webhooks this is fine. If you expect very large payloads, you need a different strategy. You can stream the request body to disk while computing the HMAC, but you cannot verify the signature until the whole body is read. The provider also has a maximum body size. GitHub limits webhook payloads to 25 MB, and Stripe's limit is lower. For local testing, the default memory limit is fine.

Graceful shutdowns

When the tunnel closes, the handler may still receive in-flight requests. Implement a graceful shutdown that stops accepting new connections, waits for existing requests to finish, and closes the database pool. This is especially important if you are testing delivery semantics with a CLI that retries.

A minimal CI workflow for webhook handlers

Once the local curl checks pass, add them to CI. You can write a small test that starts the server, sends a signed request, and asserts on the response. Here is a minimal example using Node's built-in test runner and global fetch:

import { createHmac } from 'node:crypto';

const secret = process.env.TEST_SECRET;
const body = JSON.stringify({ action: 'opened', issue: { number: 1 } });
const signature = 'sha256=' + createHmac('sha256', secret).update(body).digest('hex');

const response = await fetch('http://localhost:3000/webhook', {
  method: 'POST',
  headers: {
    'content-type': 'application/json',
    'x-github-event': 'issues',
    'x-github-delivery': 'test-delivery-1',
    'x-hub-signature-256': signature,
  },
  body,
});

assert.equal(response.status, 202);
Enter fullscreen mode Exit fullscreen mode

Add a second test that corrupts the signature and expects 401, and a third that sends the same delivery ID twice and expects the second response to be 200. These three tests will protect you from the most common webhook regressions: missing raw body, broken signature comparison, and duplicate handling.

Troubleshooting

Every delivery gets 415

Your handler did not receive raw JSON bytes. Either the GitHub webhook content type is set to application/x-www-form-urlencoded, or a global app.use(express.json()) runs before the webhook route and consumes the body first. Mount express.raw() on the webhook route only, exactly as in the code above.

Every delivery gets 401

The secret in GitHub and the one in GITHUB_WEBHOOK_SECRET differ. Common causes are a trailing newline when you pasted the value, a leading or trailing space, or a stray quote. Compare the two values with wc -c and od -c, then use the Redeliver button in GitHub to send a fresh delivery.

GitHub reports a timeout or failed delivery

Either the handler took longer than 10 seconds, or the tunnel was down when GitHub sent the request. GitHub does not retry failed deliveries by itself. Fix the cause and click Redeliver in Recent deliveries. Deliveries from the past 3 days are listed there.

Deliveries stopped after an hour

The free tunnel hit its 60-minute limit. Restart it and update the payload URL in GitHub, or use a Pro token with a persistent subdomain.

Nothing shows up at all

Check that the payload URL ends with /webhook and uses the HTTPS address. If the Web Debugger shows no request, GitHub never reached the tunnel. If it shows the request, the problem is on teh local side, in the route or handler.

Curl works but GitHub does not

The most common difference is content type. GitHub sends application/json when configured, but the signature header is case sensitive. Also check whether the secret string contains a newline. openssl rand -hex 32 does not add a newline, but some terminal copy operations do.

Practical debugging tips

When a webhook handler misbehaves, the fastest way to find the problem is to compare what the provider sent with what the handler received. Log the raw body length, the event type, and the signature header value. Log the delivery ID too; support requests are much easier to investigate when you can search by that value.

You can also add a temporary console.log before and after JSON.parse to see whether the body is a Buffer or a string. Many 415 errors come from the route not receiving the raw body because a global JSON parser ran first. A quick log of typeof req.body and Buffer.isBuffer(req.body) will tell you which one it is.

Use the Web Debugger's Modify and Replay feature to test edge cases. Change the Content-Type header to text/plain, remove the signature header, or swap the delivery ID to a fresh value. Each test gives you a deterministic response without touching GitHub.

Conclusion

Receiving webhooks on localhost comes down to a public URL that forwards to your local port and a handler that refuses anything it cannot verify. Once you get the loop working with GitHub's ping event, turn the curl checks from Step 1 into automated tests. A correctly signed request returns 202, a repeat returns 200, and a wrong signature returns 401. Keep those tests in CI.

A global JSON parser added months later will turn every real delivery into a 415 error. A test that sends a raw body and expects 202 catches that regression before production does.

The same mental model applies to Stripe, Slack, Shopify, and everyone else. Learn the raw body rule, learn the signature header, and learn the retry window. If you can handle GitHub webhooks locally, you can handle the others.

Reference

Top comments (1)

Collapse
 
skepagent profile image
SkepAgent •

把签名校验放在 raw bytes 上这点写得很扎实,很多人在这里被 express.json() 坑过。补充一个生产经验:去重只靠内存里的 Set 不够,重启就丢,最好在写入业务表时给 delivery ID 建唯一索引、和业务写入放在同一个事务里,重复投递自然就撞库了。还有 GitHub 那 10 秒超时窗口——尽快回 202,把重逻辑扔进队列,这一点做对了深夜告警会少一半。