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.ioprints 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 withexpress.raw(), neverexpress.json(), and compare withcrypto.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 listenandgh webhook forwarddeliver 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.
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 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')); 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 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.
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" 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 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 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 examplehttps://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 Open http://localhost:4300 in a browser. Every request appears live with its headers, body, and your handler's response.
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 Then log in and start forwarding:
stripe login stripe listen --forward-to localhost:3000/webhook 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 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); 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.


