How to Test Webhooks Without Deploying: A Practical Guide
This guide has a free tool → Open Webhook Tester
# How to Test Webhooks Without Deploying: A Practical Guide
The Webhook Testing Problem
You are integrating with Stripe, GitHub, Slack, Shopify, or any service that sends webhooks. The concept is simple: something happens on their platform, they send an HTTP POST request to a URL you control, and your code processes it. Clean in theory.
In practice, you hit the wall almost immediately. Your local development server runs on localhost:3000. That address is not accessible from the internet. The webhook provider cannot reach it. You are stuck in a loop: you need to see real webhook payloads to write the handler, but you need the handler deployed to receive the payloads.
The traditional workaround is to deploy to a staging environment to test. That approach has real costs:
- Every code change requires a deployment cycle
- Staging environments may differ from local in subtle ways
- Debugging requires reading logs from a remote server
- You cannot use local breakpoints or inspect state in real time
- If something goes wrong, the webhook fires and you miss it permanently - no easy replay
This guide covers practical techniques for testing webhooks at every stage of development, from initial payload capture through production debugging.
---
Webhook Tester
Webhook tester online - generate temporary webhook URLs and inspect incoming HTTP requests for free
HTTP Request Builder
Free online HTTP request builder - build and test HTTP requests with custom headers and body
SSL Certificate Checker
Free online SSL certificate checker - check SSL certificate details and expiration
Understanding the Webhook Flow
Before building a handler, understand what actually happens when a webhook fires.
The Provider Side
- An event occurs on the provider's platform (payment completed, repository pushed, form submitted)
- The provider queues a webhook delivery
- The provider sends an HTTP POST to your configured endpoint URL
- The provider waits for a response (typically 10-30 seconds depending on the provider)
- If your endpoint does not respond with a 2xx status code in time, the provider marks the delivery as failed
- Most providers retry failed deliveries on a backoff schedule (e.g., 1 minute, 5 minutes, 30 minutes, 2 hours, 24 hours)
The Consumer Side
- Your endpoint receives the POST request
- Your server processes the request (verify signature, parse body, handle event)
- Your server returns a 2xx response as quickly as possible
- Your server performs the actual business logic (asynchronously)
The most important constraint: respond fast. If your handler does too much work synchronously before sending the response, the provider may time out and retry - causing duplicate processing.
---
What a Webhook Payload Looks Like
Before writing any handler code, examine what real webhook payloads contain. A typical payment webhook looks like this:
{
"id": "evt_1OtExample",
"type": "payment_intent.succeeded",
"created": 1708864800,
"data": {
"object": {
"id": "pi_3OtExample",
"amount": 2999,
"currency": "usd",
"status": "succeeded",
"customer": "cus_ExampleId",
"metadata": {
"order_id": "order_789",
"user_id": "user_456"
},
"receipt_email": "customer@example.com"
}
},
"livemode": false,
"api_version": "2024-06-20"
}The HTTP request carrying this payload includes several important headers beyond Content-Type:
| Header | Provider | Purpose |
|---|---|---|
Stripe-Signature | Stripe | HMAC signature for verification |
X-Hub-Signature-256 | GitHub | SHA-256 HMAC signature |
X-Shopify-Hmac-Sha256 | Shopify | Base64-encoded HMAC |
X-Slack-Signature | Slack | v0 HMAC signature with timestamp |
X-Twilio-Signature | Twilio | SHA-1 HMAC signature |
Svix-Signature | Svix-based | v1 signature format |
X-WC-Webhook-Signature | WooCommerce | Base64 HMAC-SHA256 |
Webhook-Id | Various | Unique delivery ID for deduplication |
Webhook-Timestamp | Various | Delivery timestamp for replay protection |
The signature header is critical. It proves the payload came from the provider and has not been tampered with. You should never process a webhook without verifying the signature.
---
Step 1: Capture the Payload First
The first step in webhook development is seeing what the provider actually sends. Documentation is often incomplete, outdated, or shows examples that differ from what arrives in practice. Capture a real request before writing a single line of handler code.
Use Webhook Tester to generate a unique, temporary URL. Paste that URL into the webhook provider's configuration. Trigger a test event from the provider's dashboard. The tester captures the full HTTP request: method, all headers, the complete body, timing, and source IP.
This gives you several valuable things:
- The exact JSON structure of the payload (including fields the docs omit)
- All headers the provider sends (including undocumented ones)
- The signature format and which header contains it
- Whether the provider sends
application/jsonorapplication/x-www-form-urlencoded(some do the latter) - The approximate timing between event and delivery
Inspecting a Real Stripe Payload
A captured Stripe payment webhook might reveal something the docs buried in a footnote: that event.data.object contains the full payment intent object at the moment of the event, not a reference you have to fetch separately. That changes how you write your handler.
Inspecting a Real GitHub Payload
A captured GitHub push webhook shows you the exact structure of the commits array, the ref format (refs/heads/main not just main), and which SHA fields are present. The docs abstract these details.
---
Step 2: Build Your Handler
Once you understand the payload structure, build the handler. Start with the critical non-negotiables before any business logic.
The Basic Handler Structure
// Express.js example
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
// Step 1: Return 200 immediately
res.status(200).json({ received: true });
// Step 2: Verify signature (after responding? No - verify BEFORE responding)
// See next section for correct order
});Actually, the correct order is:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
// Step 1: Verify signature FIRST
const signature = req.headers['stripe-signature'];
let event;
try {
event = stripe.webhooks.constructEvent(
req.body, // Must be raw Buffer, not parsed JSON
signature,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return res.status(400).send(`Webhook Error: ${err.message}`);
}
// Step 2: Return 200 to acknowledge receipt
res.status(200).json({ received: true });
// Step 3: Process asynchronously
try {
await processWebhookEvent(event);
} catch (err) {
console.error('Webhook processing error:', err);
// Do NOT return a 500 here - we already sent 200
// Queue for retry on your side instead
}
});Handler Rules
Return 2xx before doing heavy work. The response must be sent before the provider's timeout (10-30 seconds). If you are inserting database records, sending emails, calling other APIs, do it after sending the response.
Use `express.raw()` for signature verification. Stripe, GitHub, and others require the raw request body bytes to compute the HMAC. If you use express.json() before the webhook route, the body has been parsed and serialized, which changes whitespace and key ordering - breaking the signature.
Make handlers idempotent. Providers retry deliveries. Your handler will be called more than once for the same event. Use the event ID to deduplicate:
async function processWebhookEvent(event) {
// Check if we've seen this event before
const existing = await db.webhookEvents.findOne({ eventId: event.id });
if (existing) {
console.log(`Duplicate event ${event.id}, skipping`);
return;
}
// Record that we're processing this event
await db.webhookEvents.insertOne({ eventId: event.id, processedAt: new Date() });
// Now do the actual work
switch (event.type) {
case 'payment_intent.succeeded':
await fulfillOrder(event.data.object);
break;
case 'customer.subscription.deleted':
await cancelMembership(event.data.object.customer);
break;
default:
console.log(`Unhandled event type: ${event.type}`);
}
}A Complete GitHub Webhook Handler
const crypto = require('crypto');
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
// Verify signature
const signature = req.headers['x-hub-signature-256'];
const hmac = crypto.createHmac('sha256', process.env.GITHUB_WEBHOOK_SECRET);
hmac.update(req.body);
const digest = 'sha256=' + hmac.digest('hex');
if (!crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(digest))) {
return res.status(401).send('Invalid signature');
}
// Acknowledge immediately
res.status(200).json({ received: true });
// Process
const event = JSON.parse(req.body);
const eventType = req.headers['x-github-event'];
const deliveryId = req.headers['x-github-delivery'];
console.log(`GitHub event: ${eventType}, delivery: ${deliveryId}`);
if (eventType === 'push') {
const branch = event.ref.replace('refs/heads/', '');
if (branch === 'main') {
triggerDeployment(event.after);
}
}
});---
Step 3: Test Locally with Replayed Payloads
With the payload captured and the handler written, test them together against your local server. Take the exact JSON body from the captured request and replay it using cURL or an HTTP client.
Using cURL
# Basic replay
curl -X POST http://localhost:3000/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=1708864800,v1=abc123..." \
-d '{"id":"evt_1Ot...","type":"payment_intent.succeeded",...}'
# Read body from a file (cleaner for large payloads)
curl -X POST http://localhost:3000/webhooks/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: sha256=abc123..." \
-H "X-GitHub-Event: push" \
--data-binary @captured-payload.jsonUsing the HTTP Request Builder
The HTTP Request Builder provides a visual interface for constructing and sending requests. You can paste the captured headers individually, enter the JSON body, and send the request to your local server. This is faster than constructing cURL commands, especially when testing multiple event types.
Testing Signature Verification
You need a valid signature to test the verification code, which creates a chicken-and-egg problem. There are two practical approaches:
Option 1: Use the provider's CLI. The Stripe CLI can forward live webhook events to your local server with valid signatures:
stripe listen --forward-to localhost:3000/webhooks/stripeThis handles the signature automatically because the Stripe CLI knows your webhook secret.
Option 2: Generate test signatures. If you want to test signature verification code independently, generate the correct HMAC for your test payload using your development webhook secret:
// Generate a Stripe-compatible test signature
const crypto = require('crypto');
const payload = JSON.stringify(testPayload);
const timestamp = Math.floor(Date.now() / 1000);
const secret = 'whsec_test_secret_from_your_env';
const signedPayload = `${timestamp}.${payload}`;
const hmac = crypto.createHmac('sha256', secret.replace('whsec_', ''));
hmac.update(signedPayload);
const signature = hmac.digest('hex');
const stripeSignatureHeader = `t=${timestamp},v1=${signature}`;---
Handling Webhook Failures
Most webhook-related bugs fall into a small number of categories. Knowing them in advance lets you build resilience from the start.
Your Endpoint Returns 5xx
When your handler throws an unhandled error, your framework returns a 500. The provider sees the error, marks the delivery failed, and retries. Depending on the retry schedule, your database may receive multiple inserts or your fulfillment system may ship the same order twice.
The fix: wrap everything in try-catch. Return 200 even if processing fails. Queue failed events for retry on your side with proper deduplication.
app.post('/webhooks', async (req, res) => {
res.status(200).json({ received: true }); // Always acknowledge
try {
await processEvent(req.body);
} catch (err) {
// Log and queue for internal retry - don't let the provider retry
await failedEventQueue.push({ payload: req.body, error: err.message });
}
});Signature Verification Fails
The most common cause is a body parser middleware running before the webhook route. If express.json() or body-parser touches the request body, the raw bytes change (whitespace normalization, key reordering), and the HMAC no longer matches.
Solution: use express.raw() specifically for webhook routes, placed before any JSON parsing middleware:
// This must come BEFORE express.json() for the whole app
app.post('/webhooks/stripe',
express.raw({ type: 'application/json' }),
stripeWebhookHandler
);
// General JSON parsing for all other routes
app.use(express.json());Timeout on the Provider Side
Stripe allows 20 seconds. GitHub allows 10 seconds. Slack allows 3 seconds. If your handler sends a database query, calls an external API, generates a PDF, or sends an email synchronously, you will routinely exceed these limits.
Pattern: acknowledge first, queue work, process asynchronously.
// Minimal synchronous path
app.post('/webhooks', async (req, res) => {
// Verify signature (~1ms)
const event = verifySignature(req);
// Queue the work (~5ms)
await queue.push({ event, receivedAt: Date.now() });
// Respond - total time < 10ms
res.status(200).json({ received: true });
});
// Separate worker processes the queue
queue.process(async (job) => {
await processEvent(job.data.event);
});Events Arrive Out of Order
Webhook deliveries are not guaranteed to arrive in the order events occurred. A subscription.updated event might arrive before subscription.created. Your handler must handle this gracefully.
Design for idempotency and state-based logic rather than event sequence:
async function handleSubscriptionUpdated(subscription) {
// Don't assume we've seen "created" first
// Upsert based on current state, not assumed previous state
await db.subscriptions.updateOne(
{ stripeId: subscription.id },
{
$set: {
status: subscription.status,
currentPeriodEnd: subscription.current_period_end,
updatedAt: new Date()
}
},
{ upsert: true } // Create if not exists
);
}Webhook Not Firing at All
Before debugging your handler, verify the webhook is actually being sent. Use Webhook Tester to capture deliveries and confirm the provider is sending them. If nothing appears in the tester, the problem is configuration on the provider side (wrong URL, inactive webhook, event type not selected).
---
Webhook Security in Depth
Security for webhook endpoints deserves more attention than it typically gets.
Always Verify Signatures
Never process a webhook without verifying the signature. An attacker who knows your endpoint URL can send arbitrary payloads. Without signature verification, they can trigger any action your handler performs: fulfill orders without payment, delete data, grant access to paid features.
Use Timing-Safe Comparison
When comparing the expected signature to the received signature, always use a timing-safe comparison. String comparison with === has different execution times depending on where strings first differ - this leaks information that can help an attacker forge valid signatures through timing attacks.
// Vulnerable
if (expectedSignature === receivedSignature) { ... }
// Correct
if (crypto.timingSafeEqual(
Buffer.from(expectedSignature),
Buffer.from(receivedSignature)
)) { ... }Validate Timestamps to Prevent Replay Attacks
Some providers include a timestamp in the signed payload. Verify that the timestamp is recent (within a few minutes) to prevent an attacker from capturing a valid request and replaying it hours or days later.
// Stripe signature format: t=1708864800,v1=abc123
const [tPart, v1Part] = stripeSignature.split(',');
const timestamp = parseInt(tPart.split('=')[1]);
const nowSeconds = Math.floor(Date.now() / 1000);
// Reject payloads older than 5 minutes
if (Math.abs(nowSeconds - timestamp) > 300) {
throw new Error('Webhook timestamp too old - possible replay attack');
}IP Allowlisting (Optional)
Some providers publish the IP addresses from which they send webhooks. You can restrict your webhook endpoint to only accept requests from those IPs. This is a defense-in-depth measure, not a replacement for signature verification - IP addresses can be spoofed and provider IP ranges change.
Stripe publishes their IP ranges. GitHub publishes their hook IPs via the API (https://api.github.com/meta).
Use HTTPS Only
Never configure a webhook endpoint that accepts HTTP. All credentials, payload data, and signatures should be transmitted over TLS. Use SSL Certificate Checker to verify your endpoint's certificate is valid before configuring webhook providers.
---
Testing Different Event Types
Most webhook integrations handle multiple event types. Test each one explicitly.
Create a Test Matrix
| Event Type | Test Trigger | Expected Behavior |
|---|---|---|
payment_intent.succeeded | Stripe test dashboard | Order fulfilled, confirmation email sent |
payment_intent.payment_failed | Stripe test dashboard | Failure notification sent |
customer.subscription.created | Stripe test dashboard | Membership activated |
customer.subscription.deleted | Stripe test dashboard | Membership deactivated |
invoice.payment_failed | Stripe test dashboard | Past-due notice sent |
push | GitHub test webhook | CI triggered for target branch |
pull_request.opened | GitHub test webhook | Review assignment created |
For each event type, test both the happy path and error scenarios:
- What happens if the database is unavailable when processing?
- What happens if the event references an entity that does not exist in your database?
- What happens with a duplicate delivery (same event ID sent twice)?
- What happens if the customer referenced in the webhook has been deleted from your system?
Using Provider Test Modes
Most providers have a test mode or sandbox environment that generates realistic but non-live events:
Stripe: Switch to test mode in the dashboard. Use test card numbers (4242 4242 4242 4242) to trigger payment events. The Stripe CLI can replay historical events from your test account.
GitHub: Every webhook has a "Redeliver" button in the Recent Deliveries list. You can redeliver any previous event to test your handler against real historical data.
Shopify: Has a "Send test notification" button in the webhook configuration. Generates realistic-looking but non-real events.
Twilio: Has a test credentials mode and allows manual POST requests to your endpoint from their console.
---
Local Development Approaches
Testing against real providers requires your local server to be reachable. Here are the main approaches:
Using Webhook Tester as a Relay
Capture the payload at Webhook Tester, then manually replay it to your local server. This works well for initial development when you only need to test against a handful of event types.
Using the Provider's CLI
Stripe, GitHub CLI, and others provide official forwarding tools:
# Stripe CLI
stripe listen --forward-to localhost:3000/webhooks/stripe
# This also prints all received events to your terminal,
# and you can replay them with:
stripe events resend evt_1OtExampleTunneling Tools
Tools like ngrok, localtunnel, and Cloudflare Tunnel create a public HTTPS URL that forwards to your local server. This lets providers send webhooks directly to your development environment.
# ngrok
ngrok http 3000
# Your local server is now accessible at something like:
# https://abc123.ngrok.io
# Configure this URL as your webhook endpoint in the provider's dashboardThe downside: you need to update the webhook URL in the provider's settings every time the tunnel URL changes (ngrok free tier generates a new URL each session). Some providers disallow frequently changing webhook URLs.
---
Production Webhook Monitoring
Testing does not end at deployment. In production, you need visibility into webhook health.
What to Log
Every webhook delivery should log:
- Event ID
- Event type
- Received timestamp
- Processing duration
- Outcome (success, skipped as duplicate, error + reason)
- Provider-reported delivery timestamp (to measure delivery latency)
async function processWebhookWithLogging(event, headers) {
const startTime = Date.now();
const deliveryId = headers['webhook-id'] || headers['x-github-delivery'];
try {
const result = await processEvent(event);
logger.info({
type: 'webhook_processed',
eventId: event.id,
eventType: event.type,
deliveryId,
durationMs: Date.now() - startTime,
outcome: result.skipped ? 'duplicate' : 'success'
});
} catch (err) {
logger.error({
type: 'webhook_failed',
eventId: event.id,
eventType: event.type,
deliveryId,
durationMs: Date.now() - startTime,
error: err.message
});
}
}Alerting on Failures
Set up alerts for:
- Unusually high webhook error rates
- Webhook processing latency exceeding a threshold
- Missing expected event types (e.g., no payment webhooks received in 24 hours)
Webhook Dashboard
Most providers have a webhook delivery dashboard showing recent deliveries, their HTTP status, response time, and request/response bodies. Check this dashboard when debugging production issues. You can often redeliver failed webhooks directly from the dashboard after fixing the underlying issue.
---
Webhook Testing Checklist
Use this checklist before shipping any webhook integration:
Setup
- [ ] Webhook endpoint is HTTPS only
- [ ] Endpoint URL is registered with the provider
- [ ] Correct event types are selected on the provider side
- [ ] Webhook secret is stored as an environment variable (not hardcoded)
Implementation
- [ ] Signature verification is implemented and tested
- [ ] Timing-safe comparison is used for signatures
- [ ] Timestamp replay protection is in place
- [ ] Handler returns 2xx within the provider's timeout window
- [ ] Business logic runs asynchronously after the response
- [ ] Handler is idempotent (safe to call multiple times with the same event)
- [ ] Event ID deduplication is implemented
- [ ] Handler uses raw body (not parsed) for signature verification
Testing
- [ ] Real payload captured using Webhook Tester
- [ ] All expected event types tested, not just the main happy path
- [ ] Signature verification tested with both valid and invalid signatures
- [ ] Duplicate event delivery tested
- [ ] Out-of-order event delivery tested
- [ ] Malformed JSON payload tested (handler should not crash)
- [ ] Missing headers tested (handler should reject gracefully)
Monitoring
- [ ] All deliveries are logged with event ID and outcome
- [ ] Error alerting is configured
- [ ] Processing latency is tracked
- [ ] Provider's delivery dashboard has been checked at least once
---
Common Webhook Integrations Quick Reference
| Provider | Signature Header | Algorithm | Timeout | Retry Schedule |
|---|---|---|---|---|
| Stripe | Stripe-Signature | HMAC-SHA256 | 20s | 3 retries over 3 days |
| GitHub | X-Hub-Signature-256 | HMAC-SHA256 | 10s | 3 retries |
| Shopify | X-Shopify-Hmac-Sha256 | HMAC-SHA256 | 5s | 19 retries over 48h |
| Slack | X-Slack-Signature | HMAC-SHA256 | 3s | 3 retries |
| Twilio | X-Twilio-Signature | HMAC-SHA1 | 15s | Varies |
| PayPal | N/A (cert-based) | RSA-SHA256 | 30s | Varies |
| SendGrid | N/A | ECDSA | 30s | Up to 24h |
| HubSpot | X-HubSpot-Signature | HMAC-SHA256 | 10s | 10 retries over 24h |
---
Debug Faster with ToolBox
The Webhook Tester generates unique URLs that capture incoming webhook requests in real time. No signup, no deployment, no tunnels to configure. See the complete HTTP request - method, all headers, full body, and timing - for every delivery.
When you are ready to send test requests to your local handler, use the HTTP Request Builder to construct requests with the exact headers and body the provider sends. Save request configurations and replay them across development sessions.
If you need to verify request signatures or generate test HMAC values, the Hash Generator supports HMAC-SHA256 so you can manually compute and verify webhook signatures. And once your integration is live, the HTTP Headers Checker lets you inspect what your webhook endpoint returns in its response headers to confirm correct configuration.
Related Tools
Free, private, no signup required
You might also like
Want higher limits, batch processing, and AI tools?