Why test webhooks locally?
Webhooks connect services in real time, but building and debugging receivers can be painful if every change requires deploying. Local testing speeds development, makes debugging easier, and helps validate security (signatures, retries, idempotency) before you touch production.
Common approaches
- Tunnel a local server — expose localhost via ngrok or localtunnel to receive real service webhooks.
- Use a request-bucket — webhook.site, Pipedream, or requestbin variants capture payloads without needing a live server.
- Run a service emulator — some platforms (Stripe CLI, Twilio CLI) can forward test webhooks directly to your machine.
- Simulate events locally — create small test clients that POST signed payloads to your handler.
- CI-driven integration — run integration tests in CI using ephemeral endpoints or local tunnels.
Tradeoffs at a glance
- Tunnels (ngrok/localtunnel): fast and realistic; external dependency and potential security exposure if misconfigured.
- Request bins: zero setup but limited automation; can't test signature verification unless you re-post manually.
- Emulators: best for platform-specific behavior; not always available for every provider.
- Local simulation: complete control, but you might miss subtle differences in provider payload formatting or headers.
Secure signature verification (Node.js)
Many providers sign webhook payloads with an HMAC. Verify signatures locally to catch parsing and crypto bugs early. Example: an Express receiver that uses X-Signature header with HMAC-SHA256.
const express = require('express');
const crypto = require('crypto');
const app = express();
// Keep raw body so HMAC is computed on exact bytes
app.use(express.raw({ type: 'application/json' }));
const SECRET = process.env.WEBHOOK_SECRET || 'change-me';
function validSignature(rawBody, headerValue) {
if (!headerValue) return false;
// header expected like: "sha256=hex"
const expected = 'sha256=' + crypto.createHmac('sha256', SECRET).update(rawBody).digest('hex');
try {
const a = Buffer.from(headerValue);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return crypto.timingSafeEqual(a, b);
} catch (e) {
return false;
}
}
app.post('/webhook', (req, res) => {
const sig = req.headers['x-signature'];
if (!validSignature(req.body, sig)) return res.status(401).send('invalid signature');
let payload;
try {
payload = JSON.parse(req.body.toString('utf8'));
} catch (e) {
return res.status(400).send('invalid json');
}
// TODO: handle idempotency and async processing
console.log('received event', payload.type || 'unknown');
res.status(204).end();
});
app.listen(3000, () => console.log('listening on 3000'));Same pattern in Python (Flask)
Flask example: read raw bytes with request.get_data() and verify HMAC. Adjust header name and digest format to match your provider.
from flask import Flask, request, abort
import hmac
import hashlib
import os
app = Flask(__name__)
SECRET = os.environ.get('WEBHOOK_SECRET', 'change-me')
def valid_signature(raw, header_val):
if not header_val:
return False
expected = 'sha256=' + hmac.new(SECRET.encode(), raw, hashlib.sha256).hexdigest()
try:
return hmac.compare_digest(header_val, expected)
except Exception:
return False
@app.route('/webhook', methods=['POST'])
def webhook():
raw = request.get_data() # raw bytes
sig = request.headers.get('X-Signature')
if not valid_signature(raw, sig):
abort(401)
try:
payload = request.get_json(force=True)
except Exception:
abort(400)
# process payload (use idempotency keys, enqueue work, etc.)
return ('', 204)
if __name__ == '__main__':
app.run(port=3000)Practical local workflows
- Start your local receiver and expose a stable tunnel URL (ngrok/localtunnel). Keep the tunnel URL private and rotate it regularly.
- Register the tunnel URL with the provider’s developer dashboard. Send test events from their UI.
- Use request-bins to capture raw provider requests if you don’t want a tunnel: forward events by re-posting raw captured bytes to your local endpoint to test signature logic.
- Simulate retries and duplicate events to exercise idempotency. Implement an idempotency key store (in-memory for dev, persistent for production).
- Automate tests: commit small test clients that generate signed payloads and run them in CI against your receiver (using ephemeral tunnels or a local runner).
Idempotency and retry handling
Design your webhook handler for at-least-once delivery:
- Require a unique event ID or idempotency key in the payload/header.
- Persist processed IDs for a time-window and reject duplicates quickly.
- Make handlers fast: enqueue heavy work to background workers and respond 200/204 to acknowledge.
- Log retries and failures with enough context to replay events safely from logs.
Automation tips
- Include a test client in your repo that crafts correctly signed payloads so you can run local end-to-end tests without the provider.
- Use environment variables to switch secrets and to enable/disable strict verification during local debugging.
- When using tunnels in CI, prefer short-lived credentials and restrict target endpoints with HMAC verification.
Resources
- webhook.site — quick request bin for inspecting raw requests.
- ngrok and localtunnel — local tunneling tools.
Conclusion
Testing webhooks locally is a mix of the right tooling and defensive code. Use tunnels or request bins to receive real events, but always implement proper signature verification, idempotency, and lightweight handler logic. Include signed test clients and CI checks to catch regressions early. These practices keep webhook development fast, repeatable, and safe.
Was this helpful?
Share this post
Comments (0)
Want to join the conversation?
Log in or sign up to leave a comment and share your thoughts.
Log in to Comment