How Webhooks Work
Webhooks are HTTP callbacks. When an event occurs in a provider system (payment completed, form submitted, code pushed), the provider sends an HTTP POST request to a URL you have registered.
1. You register: "Send events to https://myapp.com/webhooks/stripe"
2. Event occurs in Stripe: payment_intent.succeeded
3. Stripe sends POST to https://myapp.com/webhooks/stripe
Body: { "type": "payment_intent.succeeded", "data": { ... } }
4. Your server processes the event and returns 200 OK
5. Stripe marks the webhook as delivered
Common Webhook Providers
| Provider | Events | Signature Method |
|---|---|---|
| Stripe | Payments, subscriptions | HMAC-SHA256 with Stripe-Signature header |
| GitHub | Push, PR, issues | HMAC-SHA256 with X-Hub-Signature-256 header |
| Slack | Messages, interactions | HMAC-SHA256 with X-Slack-Signature header |
| Twilio | SMS, calls | Auth token in request params |
What to Test
1. Payload Validation
Verify your receiver correctly parses the webhook payload:
// Test: Valid Stripe webhook payload
const payload = {
id: 'evt_123',
type: 'payment_intent.succeeded',
data: {
object: {
id: 'pi_123',
amount: 2000,
currency: 'usd',
status: 'succeeded',
}
}
};
const response = await fetch('http://localhost:3000/webhooks/stripe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
expect(response.status).toBe(200);
// Verify: payment recorded in database
2. Signature Verification
Test that your receiver rejects unsigned or tampered payloads:
// Test: Missing signature — should be rejected
const response = await fetch('http://localhost:3000/webhooks/stripe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
// No Stripe-Signature header
});
expect(response.status).toBe(401);
// Test: Invalid signature — should be rejected
const response2 = await fetch('http://localhost:3000/webhooks/stripe', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Stripe-Signature': 't=1234,v1=invalidsignature',
},
body: JSON.stringify(payload),
});
expect(response2.status).toBe(401);
3. Idempotency
The same webhook may be delivered multiple times. Your receiver must handle duplicates:
// Send the same event twice
await fetch(webhookUrl, { method: 'POST', body: JSON.stringify(payload) });
await fetch(webhookUrl, { method: 'POST', body: JSON.stringify(payload) });
// Verify: only one payment record created, not two
const payments = await db.query('SELECT * FROM payments WHERE stripe_event_id = $1', ['evt_123']);
expect(payments.length).toBe(1);
4. Error Handling and Retry
Test what happens when your receiver fails:
- Return 500: Provider should retry.
- Return 200 but processing fails internally: Your system should queue for reprocessing.
- Receiver is down: Provider retries with exponential backoff.
5. Event Ordering
Webhooks may arrive out of order. A payment.refunded event might arrive before payment.succeeded:
// Send events out of order
await sendWebhook({ type: 'payment.refunded', data: { ... } });
await sendWebhook({ type: 'payment.succeeded', data: { ... } });
// Verify: system handles this gracefully
Testing Tools
webhook.site
A free service that gives you a unique URL to receive and inspect webhooks:
- Go to https://webhook.site — you get a unique URL.
- Configure your provider to send webhooks to that URL.
- Trigger events and inspect the received payloads in your browser.
ngrok
Exposes your local development server to the internet:
# Start your local webhook receiver
node server.js # Runs on localhost:3000
# In another terminal, expose it
ngrok http 3000
# Returns: https://abc123.ngrok.io
# Configure provider to send to: https://abc123.ngrok.io/webhooks/stripe
Local Test Server
Build a simple receiver for testing:
const express = require('express');
const app = express();
const receivedWebhooks = [];
app.post('/webhooks/:provider', express.json(), (req, res) => {
receivedWebhooks.push({
provider: req.params.provider,
headers: req.headers,
body: req.body,
timestamp: new Date(),
});
res.status(200).json({ received: true });
});
app.get('/webhooks/history', (req, res) => {
res.json(receivedWebhooks);
});
app.listen(3000);
Exercise: Webhook Testing Lab
Setup
Create a webhook receiver and test it thoroughly.
Task 1: Build a Webhook Receiver
Create an Express.js webhook receiver that:
- Accepts POST requests at
/webhooks/payments. - Validates HMAC-SHA256 signature using a shared secret.
- Parses the payload and stores the event.
- Returns 200 for valid webhooks, 401 for invalid signatures, 400 for malformed payloads.
- Deduplicates events by event ID.
Task 2: Signature Testing
Write tests that verify:
- Valid signature is accepted (generate proper HMAC with the shared secret).
- Missing signature header is rejected with 401.
- Invalid signature is rejected with 401.
- Tampered payload (valid signature but modified body) is rejected.
Task 3: Idempotency Testing
- Send the same event 5 times.
- Verify only 1 record is created in the database.
- Verify all 5 requests return 200 (not just the first).
Task 4: Error Scenario Testing
- Send a malformed JSON payload — verify 400 response.
- Send a valid payload but simulate database failure — verify the receiver queues for retry.
- Send events out of order — verify the system handles it.
Task 5: Load Testing Webhooks
Some providers send many webhooks in bursts (e.g., batch processing):
- Send 100 webhooks in rapid succession (concurrent).
- Verify all 100 are received and processed.
- Verify no data corruption from concurrent processing.
Deliverables
- Webhook receiver code with signature verification.
- Test suite covering all scenarios.
- Report documenting test results and any issues found.