What Is a Webhook? A Developer's Guide to Understanding and Debugging Webhooks
If you have integrated with any modern API -- Stripe, GitHub, Shopify, Twilio, Slack -- you have encountered webhooks. They are one of those concepts that seems simple on the surface but causes real headaches when something goes wrong. This guide explains what webhooks are, how they work, and (more importantly) how to debug them when they break.
What Is a Webhook?
A webhook is an HTTP request that a service sends to your server when something happens.
That is it. There is no special protocol, no binary format, no persistent connection. A webhook is a plain HTTP POST request with a JSON body, sent from one server to another.
The difference between a webhook and a normal API call is direction. With a normal API, your code calls the service. With a webhook, the service calls your code.
Polling vs. Webhooks
Without webhooks, you would need to repeatedly ask a service "did anything change?" This is called polling.
POLLING (you ask repeatedly):
Your Server Stripe
|--- Any new payments? ------>|
|<-------- Nope. -------------|
| |
|--- Any new payments? ------>|
|<-------- Nope. -------------|
| |
|--- Any new payments? ------>|
|<-- Yes, here is one. ------|
WEBHOOKS (they tell you):
Your Server Stripe
| |
| (customer pays) |
| |
|<-- POST /webhooks/stripe ---|
| {payment: details} |
|--- 200 OK ---------------->|
Webhooks are more efficient. You do not waste resources asking repeatedly, and you learn about events as soon as they happen instead of on your next poll interval.
How Webhooks Work
The flow for setting up and receiving webhooks has four steps:
Step 1: Register your endpoint. You tell the service "send events to this URL." This is usually done through a dashboard or API call.
# Example: registering a webhook endpoint with Stripe
curl https://api.stripe.com/v1/webhook_endpoints \
-u sk_test_your_key: \
-d url="https://yourapp.com/webhooks/stripe" \
-d "enabled_events[]"="payment_intent.succeeded"
Step 2: The event occurs. A customer makes a payment, a pull request is opened, an order is placed -- whatever the triggering event is.
Step 3: The service sends an HTTP POST to your URL. The request body contains details about the event.
POST /webhooks/stripe HTTP/1.1
Host: yourapp.com
Content-Type: application/json
Stripe-Signature: t=1679012345,v1=abc123...
{
"id": "evt_1N2abc",
"type": "payment_intent.succeeded",
"data": {
"object": {
"id": "pi_3N2xyz",
"amount": 2000,
"currency": "usd",
"status": "succeeded"
}
}
}
Step 4: Your server processes the event and responds with 200 OK. The service considers the delivery successful if it receives a 2xx response within a timeout window (typically 5-30 seconds).
Anatomy of a Webhook Request
A webhook is just an HTTP request, but most services include these elements:
| Component | Purpose | Example |
|---|---|---|
| Method | Always POST | POST /webhooks/stripe |
| Content-Type | Usually JSON | application/json |
| Body | Event payload | {"type": "payment.succeeded", ...} |
| Signature header | Verify authenticity | Stripe-Signature: t=...,v1=... |
| Event type | What happened | payment_intent.succeeded |
| Timestamp | When it happened | "created": 1679012345 |
Common Failure Modes (And How to Fix Them)
Here is where things get practical. These are the webhook failures developers hit most often, in rough order of frequency.
1. Signature Verification Failure
Most services sign webhook payloads using HMAC-SHA256 or a similar scheme. Your server must verify this signature to confirm the request actually came from the service and was not tampered with.
Symptoms: Your server returns 400/401, or you reject the webhook in code. The service shows "delivery failed" in its dashboard.
Common cause: Your verification code is checking the parsed JSON body instead of the raw request body. Even a single byte difference (like whitespace changes from JSON parsing) invalidates the signature.
// WRONG: body is already parsed
app.use(express.json());
app.post('/webhook', (req, res) => {
verify(req.body, signature); // req.body is an object, not raw bytes
});
// RIGHT: use raw body for verification
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
verify(req.body, signature); // req.body is a Buffer
});
Fix: Make sure your webhook route receives the raw request body. In Express, use express.raw() on the webhook route. In other frameworks, check their docs for accessing the raw body before parsing.
2. Endpoint Not Reachable
Symptoms: The service reports delivery failure. Your server logs show nothing (no request received).
Causes:
- Your server is not running or not publicly accessible
- DNS is not configured correctly
- Firewall or security group is blocking incoming requests
- TLS certificate is expired or invalid (most services require HTTPS)
- The URL path is wrong (trailing slash matters for some frameworks)
Debugging steps:
# Test your endpoint manually
curl -X POST https://yourapp.com/webhooks/stripe \
-H "Content-Type: application/json" \
-d '{"test": true}'
# Check DNS resolution
dig yourapp.com
# Check TLS certificate
openssl s_client -connect yourapp.com:443 -servername yourapp.com
3. Timeout
Symptoms: The service reports delivery failed, but your server did receive and process the request. You see the webhook in your logs, but the service shows a failure.
Cause: Your handler took too long to respond. Most services have a timeout between 5 and 30 seconds. If your handler does heavy work (database writes, external API calls, sending emails) synchronously before responding, you will hit this.
Fix: Acknowledge the webhook immediately with a 200 response, then process asynchronously.
# Python/Flask example
@app.route('/webhook', methods=['POST'])
def handle_webhook():
event = verify_signature(request)
# Queue for async processing
task_queue.enqueue(process_event, event)
# Respond immediately
return '', 200
4. Duplicate Delivery
Symptoms: Your system processes the same event multiple times. A customer gets charged twice, or receives duplicate emails.
Cause: Webhook senders retry on failure (or perceived failure -- see timeouts above). If your first handler execution timed out but still completed, the retry delivers the same event again.
Fix: Make your handler idempotent. Store the event ID and check for duplicates before processing.
app.post('/webhook', async (req, res) => {
const event = verifyAndParse(req);
// Check if we already processed this event
const existing = await db.query(
'SELECT id FROM processed_events WHERE event_id = $1',
[event.id]
);
if (existing.rows.length > 0) {
return res.status(200).json({ already_processed: true });
}
// Process and record
await processEvent(event);
await db.query(
'INSERT INTO processed_events (event_id, processed_at) VALUES ($1, NOW())',
[event.id]
);
res.status(200).json({ received: true });
});
5. Payload Parsing Errors
Symptoms: Your server returns 500. Logs show JSON parse errors or missing fields.
Causes:
- The webhook body is not valid JSON (rare, but happens with some services that send form-encoded data)
- Your code expects fields that do not exist for this event type
- The payload structure changed (API version mismatch)
Fix: Validate the payload structure before accessing nested fields. Use optional chaining or explicit checks.
// Fragile
const email = event.data.object.customer.email;
// Defensive
const email = event?.data?.object?.customer?.email;
if (!email) {
console.warn('No customer email in event', event.id);
}
A Debugging Workflow That Works
When a webhook integration is not working, follow this order:
1. Verify the webhook is being sent. Check the sender's dashboard. Stripe, GitHub, Shopify, and most services have a webhook delivery log that shows every attempt, the response code, and the response body.
2. Verify the webhook reaches your server. Check your server logs for the incoming request. If there is nothing, the problem is network-level (DNS, firewall, TLS, routing).
3. Capture and inspect the raw payload. Use a webhook testing tool to see exactly what is being sent. This eliminates guesswork about the payload structure.
Tools for this:
- webhook.site -- instant throwaway URL, no signup needed
- HookCap (hookcap.dev) -- real-time streaming with replay capability
-
ngrok
localhost:4040-- inspect requests passing through the tunnel
Point the webhook sender at your testing URL temporarily, trigger an event, and examine the full request: method, headers, body, timing.
4. Test signature verification in isolation. Take the raw payload and headers from step 3 and run them through your verification code locally. This isolates whether the problem is in signature verification or downstream processing.
5. Test your handler with a known-good payload. Use curl or a replay tool to send the captured payload directly to your local server:
curl -X POST http://localhost:3000/webhooks/stripe \
-H "Content-Type: application/json" \
-H "Stripe-Signature: t=1679012345,v1=..." \
-d @captured-payload.json
6. Check for non-obvious failures. If the webhook is received and your server returns 200, but the expected side effects are not happening, check:
- Is the event type handled in your switch/case? Unhandled types often silently succeed.
- Is the database write failing silently?
- Is there an async processing queue, and is it running?
Retries and Reliability
Most webhook senders implement retry logic. If your server does not return a 2xx response, the sender will try again. Typical retry schedules:
- Stripe: Up to 3 days, with exponential backoff (first retry after ~1 hour)
- GitHub: 1 retry after a short delay
- Shopify: 19 retries over 48 hours
This means your server does not need to have 100% uptime to receive all webhooks -- short outages are covered by retries. But your handler must be idempotent (see failure mode #4 above), because retries mean the same event can be delivered more than once.
Security Considerations
Three rules for handling webhooks securely:
Always verify signatures. Never skip signature verification, even in development. A webhook endpoint is a publicly accessible URL. Without verification, anyone can send fake events to it.
Do not trust the payload blindly. After verifying the signature, fetch the referenced resource from the API to confirm its state. This protects against replay attacks and stale data.
Use HTTPS. Webhook payloads often contain sensitive data (customer emails, payment amounts, order details). Always use HTTPS endpoints, and reject any service that sends webhooks over plain HTTP.
Summary
Webhooks are simple in concept (one server sends an HTTP POST to another) but require care in implementation. The main things to get right:
- Verify signatures using the raw request body
- Respond quickly (200 OK), process asynchronously
- Make handlers idempotent to survive retries
- Use a capture tool during development to inspect payloads before writing handler code
- Follow the debugging workflow: sender logs, then network, then payload, then handler
Once the integration is solid, webhooks are one of the most reliable and efficient patterns for connecting services. The debugging is a one-time cost that pays off for the lifetime of the integration.
Top comments (0)