Testing Webhooks Locally: Why I Built a Better Alternative to ngrok
If you've ever built webhook integrations, you know the pain: testing webhooks locally is a nightmare. You either expose your local server to the internet (security risk) or use tunneling tools like ngrok that change URLs every time you restart (frustrating).
After building webhook integrations for years, I got tired of these limitations and built Volley CLI - a tool that makes local webhook testing actually enjoyable. Here's why it's better and how to use it.
The Problem with Traditional Approaches
ngrok and Similar Tools
Most developers use ngrok or similar tunneling tools. They work, but they have significant limitations:
❌ URLs Change on Restart
Every time you restart ngrok, you get a new URL. This means:
- Updating webhook URLs in Stripe, GitHub, Shopify, etc. constantly
- Breaking your development flow
- Losing webhook history when URLs change
❌ Exposes Your Local Server
Your local development server is exposed to the internet through a tunnel. While ngrok has some security features, it's still not ideal.
❌ Dev-Only Tool
ngrok is great for development, but you can't use the same infrastructure for production. You'll need a different solution later.
❌ Limited Monitoring
You get basic request logs, but no comprehensive webhook delivery tracking, retry management, or debugging tools.
A Better Approach: Volley CLI
I built Volley to solve these problems. It's a webhook-as-a-service platform with a CLI tool for local development. Here's how it works:
How It Works
- Create a permanent webhook URL in Volley (never changes)
- Configure your webhook provider (Stripe, GitHub, etc.) to send to that URL
- Use Volley CLI to forward webhooks to your local server
- Same URL works in production - no code changes needed
Key Advantages
✅ Permanent URLs - Never change, even when you restart
✅ No Tunneling - Your local server stays completely private
✅ Production Ready - Same infrastructure for dev and prod
✅ Built-in Monitoring - Full webhook delivery dashboard
✅ Automatic Retries - Handles failures gracefully
✅ Works Offline - Webhooks are queued if your server is down
Quick Start
Installation
# macOS
brew tap volleyhq/volley
brew install volley
# Linux
wget https://github.com/volleyhq/volley-cli/releases/latest/download/volley-linux-amd64.tar.gz
tar -xzf volley-linux-amd64.tar.gz
sudo mv volley /usr/local/bin/
Setup
- Create a free Volley account (10K events/month free tier)
- Create a webhook source in the dashboard
-
Copy your ingestion ID (e.g.,
abc123xyz) -
Configure your provider to send webhooks to:
https://api.volleyhooks.com/hook/YOUR_INGESTION_ID
Example: Node.js/Express
// server.js
const express = require('express');
const app = express();
app.use(express.json());
app.post('/webhook', (req, res) => {
console.log('Webhook received:', req.body);
// Your webhook handling logic here
res.json({ received: true });
});
app.listen(3000, () => {
console.log('Server running on http://localhost:3000');
});
Forward webhooks to localhost:
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook
That's it! Now any webhook sent to your Volley URL will be forwarded to your local server.
Example: Python/Flask
# app.py
from flask import Flask, request, jsonify
app = Flask(__name__)
@app.route('/webhook', methods=['POST'])
def webhook():
data = request.json
print(f'Webhook received: {data}')
# Your webhook handling logic here
return jsonify({'received': True})
if __name__ == '__main__':
app.run(port=3000)
Forward webhooks:
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook
Example: Go
// main.go
package main
import (
"encoding/json"
"fmt"
"log"
"net/http"
)
func webhookHandler(w http.ResponseWriter, r *http.Request) {
var data map[string]interface{}
json.NewDecoder(r.Body).Decode(&data)
fmt.Printf("Webhook received: %+v\n", data)
// Your webhook handling logic here
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]bool{"received": true})
}
func main() {
http.HandleFunc("/webhook", webhookHandler)
log.Println("Server running on http://localhost:3000")
log.Fatal(http.ListenAndServe(":3000", nil))
}
Run:
go run main.go
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook
Advanced Features
Multiple Destinations
You can forward the same webhook source to multiple local endpoints:
# Terminal 1: Main API
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook
# Terminal 2: Webhook processor
volley listen --source abc123xyz --forward-to http://localhost:3001/process
# Terminal 3: Logging service
volley listen --source abc123xyz --forward-to http://localhost:3002/log
Production Deployment
When you're ready for production, just point your Volley source to your production endpoint. No code changes needed - the same webhook URL works everywhere.
Comparison: Volley vs ngrok
| Feature | Volley | ngrok |
|---|---|---|
| Webhook URLs | ✅ Permanent, never change | ❌ Change on restart |
| Tunneling | ❌ Not required | ✅ Required |
| Local Server Privacy | ✅ Completely private | ⚠️ Exposed through tunnel |
| Built-in Retry | ✅ Automatic | ❌ No |
| Monitoring | ✅ Full dashboard | ❌ Limited |
| Production Ready | ✅ Same URL for dev/prod | ❌ Dev tool only |
| Offline Support | ✅ Webhooks queued | ❌ Must be online |
Real-World Example: Stripe Webhooks
Let's say you're building a payment integration with Stripe:
-
Create Volley source → Get permanent URL:
https://api.volleyhooks.com/hook/abc123xyz - Configure Stripe → Add webhook URL in Stripe Dashboard
-
Develop locally → Use
volley listento forward tolocalhost:3000 -
Deploy to production → Point Volley source to
https://your-api.com/webhooks - Same URL, zero code changes → Stripe keeps sending to the same Volley URL
Security Best Practices
When testing webhooks locally, always:
- Verify webhook signatures (when available)
- Use environment variables for secrets
- Handle idempotency to prevent duplicate processing
// Example: Stripe webhook verification
const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY);
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
const sig = req.headers['stripe-signature'];
try {
const event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
// Handle the event
console.log('Verified webhook:', event.type);
res.json({received: true});
} catch (err) {
console.log('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
});
Getting Started
Ready to try it? Here's everything you need:
- Volley Dashboard: volleyhooks.com (free tier: 10K events/month)
- GitHub Examples: volley-local-dev-example - Complete examples in Node.js, Python, Go, and more
- Stripe Example: volley-stripe-example - Full Stripe webhook integration
- Documentation: docs.volleyhooks.com
Why I Built This
After years of dealing with webhook testing frustrations, I decided to build something better. Volley started as a side project to solve my own problems, but I realized other developers were facing the same issues.
The goal was simple: make webhook development as smooth as possible, from local testing to production deployment. No URL changes, no tunneling, no separate infrastructure for dev and prod.
What's Next?
I'm constantly improving Volley based on developer feedback. Upcoming features:
- More webhook provider integrations
- Enhanced monitoring and debugging tools
- Webhook replay functionality
- Team collaboration features
Have questions or feedback? Drop a comment below or check out the GitHub repository.
Happy webhook testing! 🚀
P.S. - If you're working with webhook signature verification across multiple providers, I'm working on a comprehensive guide. Stay tuned!
Top comments (0)