Tools
Testing Webhooks Locally: Why I Built a Better Alternative to ngrok
2025-12-12
0 views
admin
Testing Webhooks Locally: Why I Built a Better Alternative to ngrok ## The Problem with Traditional Approaches ## ngrok and Similar Tools ## A Better Approach: Volley CLI ## How It Works ## Key Advantages ## Quick Start ## Installation ## Example: Node.js/Express ## Example: Python/Flask ## Example: Go ## Advanced Features ## Multiple Destinations ## Production Deployment ## Comparison: Volley vs ngrok ## Real-World Example: Stripe Webhooks ## Security Best Practices ## Getting Started ## Why I Built This ## What's Next? 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. 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: ❌ 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. ngrok is great for development, but you can't use the same infrastructure for production. You'll need a different solution later. You get basic request logs, but no comprehensive webhook delivery tracking, retry management, or debugging tools. 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: ✅ 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 Forward webhooks to localhost: That's it! Now any webhook sent to your Volley URL will be forwarded to your local server. You can forward the same webhook source to multiple local endpoints: 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. Let's say you're building a payment integration with Stripe: When testing webhooks locally, always: Ready to try it? Here's everything you need: 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. I'm constantly improving Volley based on developer feedback. Upcoming 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! Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:
# 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/ Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# 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/ COMMAND_BLOCK:
# 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/ COMMAND_BLOCK:
// 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');
}); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// 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');
}); COMMAND_BLOCK:
// 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');
}); CODE_BLOCK:
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook CODE_BLOCK:
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook COMMAND_BLOCK:
# 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) Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# 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) COMMAND_BLOCK:
# 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) CODE_BLOCK:
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook CODE_BLOCK:
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook CODE_BLOCK:
// 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))
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
// 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))
} CODE_BLOCK:
// 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))
} CODE_BLOCK:
go run main.go
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
go run main.go
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook CODE_BLOCK:
go run main.go
volley listen --source abc123xyz --forward-to http://localhost:3000/webhook COMMAND_BLOCK:
# 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 Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
# 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 COMMAND_BLOCK:
// 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}`); }
}); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// 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}`); }
}); COMMAND_BLOCK:
// 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}`); }
}); - Updating webhook URLs in Stripe, GitHub, Shopify, etc. constantly
- Breaking your development flow
- Losing webhook history when URLs change - 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 - 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 - Create Volley source → Get permanent URL: https://api.volleyhooks.com/hook/abc123xyz
- Configure Stripe → Add webhook URL in Stripe Dashboard
- Develop locally → Use volley listen to forward to localhost: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 - Verify webhook signatures (when available)
- Use environment variables for secrets
- Handle idempotency to prevent duplicate processing - 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 - More webhook provider integrations
- Enhanced monitoring and debugging tools
- Webhook replay functionality
- Team collaboration features
how-totutorialguidedev.toailinuxservernodepythongitgithub