← All guides

How to test webhooks on localhost

Receive Stripe, GitHub, and Slack webhooks on your local dev server using a localhost tunnel with signature verification.

Two commands to start receiving webhooks

Webhook providers need a publicly reachable URL. Your local server at localhost:3000 is not reachable from the internet. A tunnel bridges the gap by giving you a public HTTPS URL that forwards traffic to your machine.

Install taupi and start a tunnel:

curl -fsSL https://taupi.dev/install.sh | sh
taupi tunnel 3000

taupi prints a URL like https://bafoli.taupi.dev. Every request to that URL is forwarded to localhost:3000 with headers, body, query string, and method intact. Keep the tunnel running in a separate terminal while you work.

If you are on the Pro plan, claim a subdomain so the URL survives restarts:

taupi tunnel --subdomain myapp 3000
# https://myapp.taupi.dev

This matters for webhooks because every time the URL changes, you have to go update the endpoint in the provider's dashboard. A stable subdomain means you configure the webhook URL once and forget about it.

Stripe webhook testing

Stripe delivers events for payments, subscriptions, invoices, disputes, and dozens of other state changes. You register a webhook endpoint in the Stripe Dashboard, and Stripe POSTs a signed JSON payload to it whenever something happens.

The handler

This is a complete Express.js handler with raw body parsing (required for signature verification), HMAC validation, and event type dispatch:

const express = require("express");
const stripe = require("stripe")(process.env.STRIPE_SECRET_KEY);

const app = express();

// Stripe signature verification requires the raw, unparsed body.
// If you use express.json() globally BEFORE this route, the signature
// check will fail because the body bytes have been re-serialized.
app.post(
  "/webhook/stripe",
  express.raw({ type: "application/json" }),
  (req, res) => {
    const sig = req.headers["stripe-signature"];
    let event;

    try {
      event = stripe.webhooks.constructEvent(
        req.body,
        sig,
        process.env.STRIPE_WEBHOOK_SECRET
      );
    } catch (err) {
      console.error(`Signature verification failed: ${err.message}`);
      return res.status(400).send(`Webhook Error: ${err.message}`);
    }

    switch (event.type) {
      case "checkout.session.completed": {
        const session = event.data.object;
        console.log(
          `Payment received: ${session.id}, amount: ${session.amount_total}, customer: ${session.customer_email}`
        );
        // Fulfill the order, send confirmation email, update DB
        break;
      }
      case "invoice.payment_failed": {
        const invoice = event.data.object;
        console.log(
          `Invoice ${invoice.id} payment failed for customer ${invoice.customer}`
        );
        // Notify the customer, retry logic, flag the account
        break;
      }
      case "customer.subscription.deleted": {
        const subscription = event.data.object;
        console.log(`Subscription ${subscription.id} canceled`);
        // Revoke access, update billing status
        break;
      }
      default:
        console.log(`Unhandled event type: ${event.type}`);
    }

    // Always respond 200 quickly. If you don't, Stripe retries.
    res.json({ received: true });
  }
);

// Other routes can use express.json() normally
app.use(express.json());

app.get("/health", (req, res) => res.json({ status: "ok" }));

app.listen(3000, () => {
  console.log("Server listening on port 3000");
});

Register the endpoint in Stripe

Go to Stripe Dashboard > Developers > Webhooks and click "Add endpoint". Enter your tunnel URL:

https://myapp.taupi.dev/webhook/stripe

Select the events you want to receive. For a typical checkout flow, you'd pick checkout.session.completed, invoice.payment_failed, and customer.subscription.deleted. Stripe gives you a webhook signing secret (whsec_...) after creating the endpoint -- put that in your STRIPE_WEBHOOK_SECRET env var.

Trigger a test event

Use the Stripe CLI to fire a synthetic event:

stripe trigger checkout.session.completed

Or click "Send test webhook" in the Stripe Dashboard on the endpoint detail page. Either way, the event hits your tunnel URL, taupi forwards it to localhost, and your handler logs the payment.

How this compares to stripe listen

The Stripe CLI has a built-in stripe listen --forward-to localhost:3000/webhook/stripe command that works without a tunnel. It connects directly to Stripe's event stream and replays events to your local server. It is a good tool for quick iteration, but there are differences worth knowing:

# Stripe CLI approach (no tunnel needed)
stripe listen --forward-to localhost:3000/webhook/stripe

# taupi approach (real webhook delivery)
taupi tunnel --subdomain myapp 3000

stripe listen uses a different signing secret than your dashboard endpoint, so you need separate STRIPE_WEBHOOK_SECRET values for each approach. It also does not test the real network path -- the request never actually traverses the public internet to your endpoint. A tunnel gives you the exact same delivery path that production will use, which catches issues like CORS misconfigurations, reverse proxy headers, and body size limits.

GitHub webhook testing

GitHub sends webhooks for pushes, pull requests, issues, releases, deployments, and many other repository events. The payloads are signed with HMAC-SHA256, and you configure them per-repository.

The handler

A Flask handler that verifies the signature, identifies the event type, and processes push and pull request events:

import hmac
import hashlib
import os

from flask import Flask, request, abort, jsonify

app = Flask(__name__)
WEBHOOK_SECRET = os.environ["GITHUB_WEBHOOK_SECRET"].encode()

def verify_signature(payload_body, signature_header):
    """Verify the GitHub webhook HMAC-SHA256 signature."""
    if not signature_header:
        return False
    expected = "sha256=" + hmac.new(
        WEBHOOK_SECRET, payload_body, hashlib.sha256
    ).hexdigest()
    return hmac.compare_digest(signature_header, expected)

@app.route("/webhook/github", methods=["POST"])
def github_webhook():
    signature = request.headers.get("X-Hub-Signature-256", "")
    if not verify_signature(request.data, signature):
        abort(403, "Invalid signature")

    event_type = request.headers.get("X-GitHub-Event")
    delivery_id = request.headers.get("X-GitHub-Delivery")
    payload = request.json

    print(f"Received {event_type} event (delivery: {delivery_id})")

    if event_type == "ping":
        print(f"Ping from webhook {payload['hook']['id']}")
        return jsonify({"msg": "pong"}), 200

    if event_type == "push":
        branch = payload["ref"].split("/")[-1]
        commits = payload.get("commits", [])
        pusher = payload["pusher"]["name"]
        print(f"Push to {branch} by {pusher}: {len(commits)} commit(s)")
        for commit in commits[:5]:
            print(f"  - {commit['id'][:7]} {commit['message'].splitlines()[0]}")

    elif event_type == "pull_request":
        action = payload["action"]
        pr = payload["pull_request"]
        print(f"PR #{pr['number']} {action}: {pr['title']}")
        print(f"  {pr['html_url']}")
        if action in ("opened", "synchronize"):
            # Run checks, post a status, trigger CI
            pass

    elif event_type == "issues":
        action = payload["action"]
        issue = payload["issue"]
        print(f"Issue #{issue['number']} {action}: {issue['title']}")

    return "", 200

if __name__ == "__main__":
    app.run(port=5000, debug=True)

Start the tunnel on port 5000:

taupi tunnel --subdomain myapp 5000

Configure the webhook in GitHub

In your repository, go to Settings > Webhooks > Add webhook. Fill in the fields:

  • Payload URL: https://myapp.taupi.dev/webhook/github
  • Content type: application/json (not application/x-www-form-urlencoded -- the form encoding is harder to parse and the JSON encoding gives you nested objects)
  • Secret: the same value as your GITHUB_WEBHOOK_SECRET environment variable
  • Events: either "Send me everything" or pick individual events like Pushes, Pull requests, Issues

Click "Add webhook". GitHub immediately sends a ping event to verify the endpoint is reachable. Check your Flask terminal -- you should see the ping logged. If you see a 403, double-check that the secret in GitHub matches the secret in your environment.

Testing without pushing code

After the webhook is configured, you can redeliver any past event from the webhook settings page. Go to Settings > Webhooks, click your webhook, and you'll see "Recent Deliveries" with a redeliver button for each one. This is useful when you are iterating on your handler logic and don't want to keep making dummy commits.

You can also send a synthetic payload with curl:

# Compute the HMAC signature
BODY='{"ref":"refs/heads/main","pusher":{"name":"testuser"},"commits":[]}'
SIG="sha256=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "your-secret" | cut -d' ' -f2)"

curl -X POST https://myapp.taupi.dev/webhook/github \
  -H "Content-Type: application/json" \
  -H "X-GitHub-Event: push" \
  -H "X-Hub-Signature-256: $SIG" \
  -d "$BODY"

Slack Events API

Slack's Events API has a twist: before it will deliver events, your endpoint must respond to a URL verification challenge. Slack POSTs a JSON body with "type": "url_verification" and a challenge string, and your server must echo the challenge back. Only after that handshake succeeds will Slack start sending real events.

The handler

A Go HTTP handler that handles the verification challenge, verifies the Slack signing secret, and processes message events:

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"fmt"
	"io"
	"net/http"
	"os"
	"strconv"
	"time"
)

type SlackPayload struct {
	Type      string `json:"type"`
	Challenge string `json:"challenge,omitempty"`
	Token     string `json:"token,omitempty"`
	Event     struct {
		Type    string `json:"type"`
		Text    string `json:"text"`
		User    string `json:"user"`
		Channel string `json:"channel"`
		TS      string `json:"ts"`
	} `json:"event,omitempty"`
}

var signingSecret = os.Getenv("SLACK_SIGNING_SECRET")

func verifySlackSignature(body []byte, timestamp, signature string) bool {
	// Reject requests older than 5 minutes to prevent replay attacks
	ts, err := strconv.ParseInt(timestamp, 10, 64)
	if err != nil {
		return false
	}
	if time.Now().Unix()-ts > 300 {
		return false
	}

	baseString := "v0:" + timestamp + ":" + string(body)
	mac := hmac.New(sha256.New, []byte(signingSecret))
	mac.Write([]byte(baseString))
	expected := "v0=" + hex.EncodeToString(mac.Sum(nil))
	return hmac.Equal([]byte(expected), []byte(signature))
}

func webhookHandler(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(r.Body)
	if err != nil {
		http.Error(w, "Bad request", http.StatusBadRequest)
		return
	}
	defer r.Body.Close()

	var payload SlackPayload
	if err := json.Unmarshal(body, &payload); err != nil {
		http.Error(w, "Invalid JSON", http.StatusBadRequest)
		return
	}

	// URL verification challenge -- must respond immediately
	if payload.Type == "url_verification" {
		w.Header().Set("Content-Type", "text/plain")
		fmt.Fprint(w, payload.Challenge)
		return
	}

	// Verify signature for all other requests
	timestamp := r.Header.Get("X-Slack-Request-Timestamp")
	sig := r.Header.Get("X-Slack-Signature")
	if !verifySlackSignature(body, timestamp, sig) {
		http.Error(w, "Invalid signature", http.StatusUnauthorized)
		return
	}

	if payload.Type == "event_callback" {
		switch payload.Event.Type {
		case "message":
			fmt.Printf("Message in %s from %s: %s\n",
				payload.Event.Channel, payload.Event.User, payload.Event.Text)
		case "app_mention":
			fmt.Printf("Mentioned by %s in %s: %s\n",
				payload.Event.User, payload.Event.Channel, payload.Event.Text)
		default:
			fmt.Printf("Event: %s\n", payload.Event.Type)
		}
	}

	w.WriteHeader(http.StatusOK)
}

func main() {
	http.HandleFunc("/webhook/slack", webhookHandler)
	fmt.Println("Listening on :8080")
	http.ListenAndServe(":8080", nil)
}
taupi tunnel --subdomain myapp 8080

Configure Slack Event Subscriptions

  1. Go to api.slack.com/apps, select your app, and open "Event Subscriptions".
  2. Toggle "Enable Events" to on.
  3. In the "Request URL" field, enter:
    https://myapp.taupi.dev/webhook/slack
    
  4. Slack immediately sends the verification challenge. If your handler is running and the tunnel is up, the URL turns green with a "Verified" checkmark within a couple of seconds.
  5. Under "Subscribe to bot events", add the events you need: message.channels, app_mention, message.im, etc.
  6. Click "Save Changes", then reinstall the app to your workspace so the new event scopes take effect.

The verification step trips people up because the challenge request happens the moment you paste the URL. Your server and tunnel both need to be running before you save the URL in the Slack dashboard. If verification fails, check that your handler returns the challenge string as plain text with a 200 status.

Inspecting payloads in real time

When a webhook handler misbehaves, the first thing you want to know is: what did the provider actually send? Use the --inspect flag to see every request and response flowing through the tunnel:

taupi tunnel --inspect --subdomain myapp 3000

With inspect mode on, taupi logs the full HTTP request and response for each tunnel request -- method, path, headers, body, status code. This is faster than adding console.log calls throughout your handler, and it shows you what taupi received from the provider before your application touches it.

--> POST /webhook/stripe
    Content-Type: application/json
    Stripe-Signature: t=1716000000,v1=abc123...
    Body: {"id":"evt_1abc","type":"checkout.session.completed",...}

<-- 200 OK (12ms)
    Body: {"received":true}

This is especially useful for debugging signature verification failures, because you can see the exact headers and body that arrived and compare them against what your code is checking.

Custom subdomains for stable webhook URLs

Free-tier tunnels get a random subdomain that changes every time you restart the tunnel. For webhook development, this is painful -- you'd have to update the endpoint URL in Stripe, GitHub, Slack, or wherever else every time you restart.

With a Pro plan subdomain, you configure the webhook URL once:

# Always the same URL
taupi tunnel --subdomain payments 3000
# https://payments.taupi.dev

Register https://payments.taupi.dev/webhook/stripe in the Stripe Dashboard and never touch it again. Restart the tunnel, reboot your machine, come back the next day -- the URL resolves to your local server as soon as the tunnel is running.

If you work on multiple projects, use different subdomains for each:

# Project A - payment service
taupi tunnel --subdomain payments 3000

# Project B - CI bot
taupi tunnel --subdomain ci-bot 8080

Troubleshooting

Signature verification fails

The most common cause is a mismatch between the raw body the provider signed and the body your code verifies against. This happens when body-parsing middleware runs before your webhook route.

In Express, the fix is to use express.raw() on the webhook route specifically, and place express.json() after it or only on other routes:

// WRONG: global JSON parsing destroys the raw body
app.use(express.json());
app.post("/webhook/stripe", (req, res) => {
  // req.body is a parsed object, not raw bytes
  // stripe.webhooks.constructEvent will fail
});

// RIGHT: raw body on the webhook route, JSON on everything else
app.post("/webhook/stripe", express.raw({ type: "application/json" }), handler);
app.use(express.json());

Another cause: clock skew. Stripe and Slack both include a timestamp in the signature and reject requests where the timestamp is more than a few minutes off. If your system clock is wrong, signature verification fails even though the secret is correct. Check with date and sync with NTP if needed:

# Check your system time
date -u

# Compare against an NTP server
ntpdate -q pool.ntp.org

A third cause: using the wrong secret. Stripe gives you a different webhook signing secret for each endpoint. If you have multiple endpoints (one for test mode, one for live mode), make sure STRIPE_WEBHOOK_SECRET matches the endpoint your tunnel URL is registered on. The Stripe Dashboard shows the signing secret when you click into an endpoint's details.

Webhook returns 404

Your local server is not handling the path the provider is sending to. Verify the route path matches exactly:

# Test the route directly, bypassing the tunnel
curl -X POST http://localhost:3000/webhook/stripe \
  -H "Content-Type: application/json" \
  -d '{"test": true}'

If this returns 404, the problem is in your routing, not the tunnel. Common mistakes: the server hasn't finished starting, the route is registered under a different path, or a middleware is catching the request before it reaches the route.

Webhook times out

Webhook providers expect a response within 3-5 seconds. Stripe retries up to 3 times with exponential backoff. GitHub retries once after 10 seconds. If your handler does heavy processing (database writes, API calls, sending emails), respond immediately and process asynchronously:

app.post("/webhook/stripe", express.raw({ type: "application/json" }), (req, res) => {
  const event = verifyAndParse(req);

  // Respond immediately
  res.json({ received: true });

  // Process in the background
  processEvent(event).catch((err) =>
    console.error("Background processing failed:", err)
  );
});

Empty request body

If your handler receives an empty body, your body parsing middleware is likely not configured for the content type the provider sends. Most providers send application/json, but some (like older Slack webhook configurations) send application/x-www-form-urlencoded.

Check the content type with inspect mode:

taupi tunnel --inspect 3000

Then configure the appropriate parser. For URL-encoded bodies in Express:

app.post(
  "/webhook/legacy",
  express.urlencoded({ extended: true }),
  (req, res) => {
    console.log(req.body); // parsed form data
    res.sendStatus(200);
  }
);

Tunnel URL not reachable

Make sure taupi is still running. If the process was killed or the network dropped, the public URL stops working. taupi auto-reconnects after transient network issues, but if you closed the terminal, you need to restart it. On the free plan, the URL changes on restart -- update your webhook configuration accordingly, or use a Pro subdomain to avoid this.