Debugging webhooks is one of the most frustrating experiences in modern web development. You configure a Stripe payment hook, wait for a transaction, and... nothing happens. Was the request sent? Was it malformed? Did your server even receive it?
In this tutorial, I'll show you how I built an enterprise-grade webhook debugging tool using Node.js and the Apify platform, and share the key patterns that make it production-ready.
The Problem: Why Webhook Debugging Is So Painful
Every developer who has integrated with Stripe, GitHub, Shopify, or any webhook-based service has experienced these issues:
- No visibility into what data is being sent
- Localhost is unreachable from external services
- Tunneling tools like ngrok add complexity and security concerns
- Failed webhooks require reconfiguring the source service
The traditional workflow looks like this:
1. Deploy code to staging
2. Configure webhook URL
3. Trigger event
4. Check logs (if they exist)
5. Realize data is wrong
6. Repeat steps 1-5 indefinitely
This is incredibly slow. What if you could capture, inspect, and replay webhooks instantly?
The Solution Architecture
Here's the high-level architecture of a webhook debugging server:
┌─────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Stripe │────▶│ Webhook Server │────▶│ Dataset │
│ GitHub │ │ (Express.js) │ │ (Logs) │
│ Shopify │ └────────┬─────────┘ └─────────────┘
└─────────────┘ │
▼
┌──────────────────┐
│ SSE Stream │
│ (Real-time UI) │
└──────────────────┘
The core components are:
- Express server to receive webhook requests
- Logging middleware to capture full request details
- SSE broadcasting for real-time monitoring
- Persistence layer for replay functionality
Building the Core Middleware
The heart of the system is a middleware that captures every detail of incoming requests:
// logger_middleware.js
import { nanoid } from "nanoid";
function createLoggerMiddleware(webhookManager, options, broadcast) {
return async (req, res) => {
const startTime = Date.now();
const webhookId = req.params.id;
// Validate the webhook exists and hasn't expired
if (!webhookManager.isValid(webhookId)) {
return res.status(404).json({
error: "Webhook ID not found or expired",
});
}
// Capture full request details
const event = {
id: nanoid(10),
timestamp: new Date().toISOString(),
webhookId,
method: req.method,
headers: maskSensitiveHeaders(req.headers),
query: req.query,
body: parseBody(req.body),
contentType: req.headers["content-type"],
remoteIp: req.ip,
processingTime: Date.now() - startTime,
};
// Send response immediately
res.status(options.defaultResponseCode).send(options.defaultResponseBody);
// Background: Store and broadcast
await storeEvent(event);
broadcast(event); // SSE to all connected clients
};
}
Key insight: We respond to the webhook source immediately, then handle storage and broadcasting asynchronously. This ensures sub-10ms response times.
Security: Timing-Safe Authentication
When implementing API key authentication, a common mistake is using simple string comparison:
// ❌ VULNERABLE to timing attacks
if (providedKey === expectedKey) {
return { isValid: true };
}
An attacker can measure response times to guess your key character-by-character. Here's the secure approach:
// ✅ SECURE - Uses timing-safe comparison
import { timingSafeEqual } from "crypto";
export function validateAuth(req, authKey) {
if (!authKey) return { isValid: true };
const providedKey = extractKeyFromRequest(req);
if (!providedKey) {
return { isValid: false, error: "Missing API key" };
}
const expectedBuffer = Buffer.from(authKey);
const providedBuffer = Buffer.from(providedKey);
// timingSafeEqual requires same-length buffers
if (expectedBuffer.length !== providedBuffer.length) {
// Perform dummy comparison to avoid timing shortcut
timingSafeEqual(expectedBuffer, expectedBuffer);
return { isValid: false, error: "Invalid API key" };
}
const isValid = timingSafeEqual(expectedBuffer, providedBuffer);
return { isValid, error: isValid ? null : "Invalid API key" };
}
This prevents timing-based attacks by ensuring the comparison always takes the same amount of time, regardless of how many characters match.
Real-Time Monitoring with Server-Sent Events
Instead of polling for new webhooks, we use Server-Sent Events (SSE) for instant updates:
// Set up SSE endpoint
const clients = new Set();
app.get("/log-stream", (req, res) => {
// SSE headers
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.flushHeaders();
// Track this client
clients.add(res);
// Clean up on disconnect
req.on("close", () => clients.delete(res));
});
// Broadcast to all connected clients
function broadcast(data) {
const message = `data: ${JSON.stringify(data)}\n\n`;
clients.forEach((client) => client.write(message));
}
Tip: Add a heartbeat to keep connections alive through proxies:
setInterval(() => {
clients.forEach((client) => client.write(": heartbeat\n\n"));
}, 30000);
Rate Limiting with LRU Eviction
To prevent abuse without running out of memory, we implement an in-memory rate limiter with LRU eviction:
export class RateLimiter {
constructor(limit, windowMs, maxEntries = 1000) {
this.limit = limit;
this.windowMs = windowMs;
this.maxEntries = maxEntries;
this.hits = new Map(); // Maintains insertion order for LRU
}
middleware() {
return (req, res, next) => {
const ip = req.ip;
const now = Date.now();
let userHits = this.hits.get(ip);
if (!userHits) {
// Evict oldest entry if at capacity
if (this.hits.size >= this.maxEntries) {
const oldest = this.hits.keys().next().value;
this.hits.delete(oldest);
}
userHits = [];
} else {
// LRU: Re-insert to move to end
this.hits.delete(ip);
}
// Filter to recent window only
const recentHits = userHits.filter((h) => now - h < this.windowMs);
if (recentHits.length >= this.limit) {
return res.status(429).json({ error: "Rate limit exceeded" });
}
recentHits.push(now);
this.hits.set(ip, recentHits);
next();
};
}
}
The LRU pattern ensures that frequently accessed IPs stay in memory while inactive ones get evicted first.
Hot-Reloading Configuration
One advanced feature is zero-downtime configuration updates. Using Apify's Actor events, we can update settings without restarting:
Actor.on("input", async (newInput) => {
console.log("[SYSTEM] Applying new settings...");
// Update authentication
currentAuthKey = newInput.authKey;
// Update rate limits
rateLimiter.limit = newInput.rateLimitPerMinute || 60;
// Recompile custom scripts
if (newInput.customScript !== oldScript) {
compiledScript = new vm.Script(newInput.customScript);
}
console.log("[SYSTEM] Hot-reload complete!");
});
This allows you to change API keys, rate limits, or custom scripts while the server is live.
Frequently Asked Questions
Q: How do I handle large payloads?
Set a maximum payload size and reject oversized requests early:
app.use(bodyParser.raw({ limit: "10mb", type: "*/*" }));
Q: Can I validate incoming webhook data?
Yes! Use JSON Schema validation to reject malformed payloads automatically.
Q: How do I replay a failed webhook?
Store the original request data and expose a /replay/:id endpoint that re-sends it to a new destination.
Conclusion
Building an enterprise-grade webhook debugging tool requires attention to several key areas:
- Performance: Respond immediately, process asynchronously
- Security: Use timing-safe comparisons for authentication
- Monitoring: Implement SSE for real-time visibility
- Resilience: Add rate limiting with memory-safe eviction
- Flexibility: Support hot-reloading for zero-downtime updates
The full implementation is open-source and available on GitHub. You can also try it live on the Apify Store.
For more advanced patterns like custom scripting, request forwarding, and JSON schema validation, check out the project's workflow playbooks.
Happy debugging!