Webhook Testing Made Simple: Send, Inspect, and Debug Payloads
Webhooks are the backbone of real-time integrations. When a customer pays on Stripe, when code is pushed to GitHub, when a message arrives in Slack — webhooks deliver the event to your server as an HTTP POST request. They are conceptually simple but notoriously difficult to debug because the request originates from an external system you do not control.
What is a webhook?
A webhook is an HTTP callback. Instead of your application polling an API repeatedly asking "did anything happen?", the service pushes an event to your URL when something happens.
Traditional polling:
Your App --> "Any new orders?" --> Stripe API
Your App <-- "Nope" <-- Stripe API
Your App --> "Any new orders?" --> Stripe API
Your App <-- "Nope" <-- Stripe API
Your App --> "Any new orders?" --> Stripe API
Your App <-- "Yes, here's one" <-- Stripe API
Webhook:
Stripe --> POST /webhooks/stripe --> Your App
<-- 200 OK <-- Your App
Webhooks are more efficient, lower latency, and reduce API rate limit consumption.
Anatomy of a webhook request
A typical webhook request includes:
HTTP method: Always POST (occasionally PUT).
Headers:
Content-Type: application/json
X-Webhook-Signature: sha256=abc123...
X-Request-ID: evt_1234567890
User-Agent: Stripe/1.0
Body:
{
"id": "evt_1234567890",
"type": "payment_intent.succeeded",
"created": 1714000000,
"data": {
"object": {
"id": "pi_abc123",
"amount": 5000,
"currency": "usd",
"status": "succeeded"
}
}
}
The body contains the event type, a timestamp, and the event data. The headers often include a signature for verification and a unique event ID for idempotency.
Common webhook providers
Different services structure their webhooks differently:
Stripe — Events wrapped in an envelope with type, data.object, and a Stripe-Signature header using HMAC-SHA256.
GitHub — Delivers repository events (push, pull_request, issues) with an X-Hub-Signature-256 header.
Slack — Uses a signing secret with timestamp-based HMAC verification for event subscriptions.
Despite the differences, the pattern is the same: receive a POST, verify the signature, parse the body, process the event, return a 2xx status.
Testing webhooks locally
The fundamental challenge is that webhook providers need to reach your server over the internet, but during development your server runs on localhost. There are several approaches:
1. Inspect payloads with a testing tool
Before writing any handler code, capture real payloads to understand their structure. A webhook testing tool provides a temporary URL that logs every incoming request — headers, body, timestamps, and all.
This lets you:
- See the exact payload format before writing parsing code
- Compare payloads across different event types
- Share payload examples with teammates
2. Tunnel to localhost
Tools like ngrok or cloudflared create a public URL that tunnels traffic to your local machine:
ngrok http 3000
# Forwarding: https://abc123.ngrok.io -> http://localhost:3000
Register the ngrok URL as your webhook endpoint, and requests flow to your local server. This is ideal for end-to-end testing during development.
3. Replay saved payloads
Save webhook payloads from your testing tool and replay them with curl against your local server. This is useful for reproducing specific scenarios without triggering real events.
HMAC signature verification
Never trust a webhook payload without verifying its signature. Without verification, anyone who discovers your webhook URL can send fake events.
The standard approach is HMAC (Hash-based Message Authentication Code):
const crypto = require('crypto');
function verifyWebhookSignature(payload, signature, secret) {
const expected = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Use timing-safe comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
// In your webhook handler:
app.post('/webhooks/stripe', (req, res) => {
const rawBody = req.rawBody; // Must be the raw string, not parsed JSON
const signature = req.headers['stripe-signature'];
if (!verifyWebhookSignature(rawBody, signature, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
// Safe to process the event
const event = JSON.parse(rawBody);
handleEvent(event);
res.status(200).send('OK');
});
Key points:
- Always verify against the raw request body, not the parsed JSON. Parsing and re-serializing can change the byte representation.
- Use timing-safe comparison to prevent attackers from guessing the signature one character at a time.
- Store the webhook secret in an environment variable, never in code.
Retry logic
Most webhook providers retry failed deliveries. A delivery is considered failed if your server returns a non-2xx status code or does not respond within a timeout (typically 5-30 seconds).
Common retry schedules:
- Stripe: up to 3 days with exponential backoff
- GitHub: 1 retry after a short delay
- Slack: 3 retries within 30 minutes
Your handler must be idempotent — processing the same event twice should produce the same result. Use the event ID to track which events you have already processed:
async function handleEvent(event) {
// Check if already processed
const existing = await db.webhookEvents.findOne({ eventId: event.id });
if (existing) {
return; // Already handled, skip
}
// Process the event
await processPayment(event.data.object);
// Record that we handled it
await db.webhookEvents.insert({ eventId: event.id, processedAt: new Date() });
}
Responding quickly
Return a 200 status as fast as possible. If your handler takes 30 seconds to process, the provider may time out and retry, causing duplicate processing.
The pattern is: acknowledge immediately, process asynchronously.
app.post('/webhooks', (req, res) => {
// Acknowledge receipt immediately
res.status(200).send('OK');
// Process in the background
processEventAsync(req.body).catch(console.error);
});
Try our Webhook Tester to inspect and debug webhook payloads instantly — right in your browser, no upload required.