What Are Webhooks and Why Do They Matter?
Traditional API integrations work on a pull model: your application repeatedly asks another service "has anything changed?" This polling approach wastes bandwidth, introduces latency, and puts unnecessary load on both systems. If you poll every 60 seconds, events can be delayed by up to a minute. If you poll every second, you are burning API rate limits on empty responses 99% of the time.
Webhooks flip the model. Instead of asking, you listen. You give a service a URL, and when something happens, that service sends an HTTP request to your URL with the event data. The event arrives in real time — typically within seconds — with zero wasted requests.
"Any new events?"
x 1000/hour
999 times
vs.
POST to your URL
Processes instantly
This guide walks through building webhook integrations with GitHub and Stripe, the two most common webhook providers. You will learn how to set up endpoints, verify signatures, handle retries, and process events reliably in production.
Webhook Architecture
A webhook integration has four components: the provider (GitHub, Stripe), the transport (HTTPS), your endpoint (a URL that receives POST requests), and your processing logic (what to do with the event).
Provider Responsibilities
- Detect events (push, payment, etc.)
- Serialize event data as JSON
- Sign the payload with a secret
- Send HTTP POST to your URL
- Retry on failure (usually 3-5 times)
Your Responsibilities
- Expose a public HTTPS endpoint
- Verify the signature on every request
- Respond quickly (under 5-10 seconds)
- Handle duplicate events (idempotency)
- Process events asynchronously if needed
Setting Up a Webhook Endpoint
Basic PHP Endpoint
<?php
// Only accept POST requests
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
exit('Method Not Allowed');
}
// Read the raw request body
$payload = file_get_contents('php://input');
if (empty($payload)) {
http_response_code(400);
exit('Empty payload');
}
// Parse JSON
$event = json_decode($payload, true);
if (json_last_error() !== JSON_ERROR_NONE) {
http_response_code(400);
exit('Invalid JSON');
}
// Log the event (for debugging)
file_put_contents(
'/var/log/webhooks/events.log',
date('c') . ' ' . json_encode($event) . "\n",
FILE_APPEND
);
// Respond immediately with 200
http_response_code(200);
echo 'OK';
Basic Node.js Endpoint
const express = require('express');
const app = express();
// IMPORTANT: Use raw body for signature verification
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const payload = JSON.parse(req.body);
console.log('Received webhook:', payload.action || payload.type);
// Respond immediately — process async if needed
res.status(200).send('OK');
// Process event asynchronously
processEvent(payload).catch(err => {
console.error('Event processing failed:', err);
});
});
app.listen(3000, () => console.log('Webhook server on port 3000'));
Signature Verification: HMAC-SHA256
Most webhook providers use HMAC-SHA256 to sign payloads. When you register a webhook, you provide (or receive) a shared secret. The provider computes HMAC-SHA256(secret, body) and includes it in a header. Your endpoint must compute the same HMAC and compare.
How HMAC Verification Works
HMAC(secret, body)
X-Hub-Signature-256
HMAC(secret, body)
Match = Authentic
PHP HMAC Verification
$computed = 'sha256=' . hash_hmac('sha256', $payload, $secret);
// Use timing-safe comparison to prevent timing attacks
return hash_equals($computed, $signatureHeader);
}
// Usage
$payload = file_get_contents('php://input');
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$secret = getenv('WEBHOOK_SECRET');
if (!verifyWebhookSignature($payload, $signature, $secret)) {
http_response_code(401);
exit('Invalid signature');
}
Node.js HMAC Verification
function verifySignature(payload, signatureHeader, secret) {
const computed = 'sha256=' + crypto
.createHmac('sha256', secret)
.update(payload)
.digest('hex');
// Timing-safe comparison
return crypto.timingSafeEqual(
Buffer.from(computed),
Buffer.from(signatureHeader)
);
}
===) is vulnerable to timing attacks. An attacker can determine the correct signature character by character by measuring response times. Use hash_equals() in PHP or crypto.timingSafeEqual() in Node.js.
GitHub Webhook Integration
GitHub webhooks notify your server when events occur in your repositories: pushes, pull requests, issues, releases, and dozens more.
Setting Up GitHub Webhooks
- Payload URL:
https://yourdomain.com/webhook/github - Content type:
application/json - Secret: A strong random string (store in env variable)
- Events: Select specific events you need
Common GitHub Webhook Events
| Event | Trigger | Use Case |
|---|---|---|
push | Code pushed to branch | Auto-deploy |
pull_request | PR opened/merged/closed | CI/CD trigger |
issues | Issue created/updated | Notification |
release | Release published | Deploy new version |
create | Branch/tag created | Build trigger |
delete | Branch/tag deleted | Cleanup trigger |
star | Repository starred | Analytics |
Handling a GitHub Push Event
app.post('/webhook/github', express.raw({ type: 'application/json' }), (req, res) => {
// Verify signature
const signature = req.headers['x-hub-signature-256'];
if (!verifySignature(req.body, signature, process.env.GITHUB_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = req.headers['x-github-event'];
const payload = JSON.parse(req.body);
// Respond immediately
res.status(200).send('OK');
// Route by event type
switch (event) {
case 'push':
const branch = payload.ref.replace('refs/heads/', '');
console.log(`Push to ${branch} by ${payload.pusher.name}`);
console.log(`Commits: ${payload.commits.length}`);
if (branch === 'main') {
triggerDeployment(payload);
}
break;
case 'pull_request':
console.log(`PR #${payload.number}: ${payload.action}`);
break;
case 'issues':
console.log(`Issue #${payload.issue.number}: ${payload.action}`);
break;
}
});
Stripe Webhook Integration
Stripe uses webhooks for payment events: successful charges, failed payments, subscription changes, refunds, and disputes. Stripe webhook handling requires extra care because financial data is involved.
Key Stripe Events
| Event | When It Fires | Required Action |
|---|---|---|
checkout.session.completed | Customer completes checkout | Fulfill order |
payment_intent.succeeded | Payment captured | Confirm payment |
payment_intent.payment_failed | Payment declined | Notify customer |
customer.subscription.created | New subscription | Grant access |
customer.subscription.deleted | Subscription canceled | Revoke access |
invoice.payment_failed | Recurring payment failed | Retry or notify |
charge.dispute.created | Chargeback initiated | Respond to dispute |
Stripe Webhook Handler (Node.js)
app.post('/webhook/stripe',
express.raw({ type: 'application/json' }),
async (req, res) => {
const sig = req.headers['stripe-signature'];
let event;
try {
// Stripe SDK handles signature verification
event = stripe.webhooks.constructEvent(
req.body,
sig,
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
console.error('Webhook signature failed:', err.message);
return res.status(400).send('Invalid signature');
}
// Handle specific events
switch (event.type) {
case 'checkout.session.completed':
const session = event.data.object;
await fulfillOrder(session);
break;
case 'customer.subscription.deleted':
const subscription = event.data.object;
await revokeAccess(subscription.customer);
break;
case 'invoice.payment_failed':
const invoice = event.data.object;
await notifyPaymentFailed(invoice);
break;
}
res.status(200).json({ received: true });
}
);
Idempotency and Duplicate Handling
Webhook providers retry failed deliveries, which means your endpoint may receive the same event multiple times. Your processing logic must be idempotent — processing an event twice should produce the same result as processing it once.
async function processEvent(event) {
// Check if we have already processed this event
const existing = await db.query(
'SELECT id FROM processed_events WHERE event_id = ?',
[event.id]
);
if (existing.length > 0) {
console.log(`Event ${event.id} already processed, skipping`);
return;
}
// Process the event
await handleEvent(event);
// Record that we processed it
await db.query(
'INSERT INTO processed_events (event_id, processed_at) VALUES (?, NOW())',
[event.id]
);
}
Retry Policies
Different providers have different retry behaviors. Understanding these helps you design your error handling:
| Provider | Retry Count | Retry Schedule | Timeout |
|---|---|---|---|
| GitHub | Up to 3 | Immediate, then increasing delays | 10 seconds |
| Stripe | Up to 3 (or more) | Exponential backoff over 72 hours | 20 seconds |
| Slack | Up to 3 | Immediately, 1 min, 5 min | 3 seconds |
| GitLab | 0 (no retries) | N/A | 10 seconds |
| Twilio | Up to 2 | At request intervals | 15 seconds |
Webhook Security Best Practices
Authentication
- Always verify HMAC signatures
- Use timing-safe string comparison
- Store secrets in environment variables
- Rotate secrets periodically
Transport Security
- Always use HTTPS endpoints
- Use valid SSL certificates
- Consider IP whitelisting for critical webhooks
- Reject non-POST requests
Processing Safety
- Validate event data before processing
- Handle duplicates (idempotency)
- Set processing timeouts
- Log all events for debugging
Error Handling
- Return 200 even if async processing fails
- Implement dead letter queues for failures
- Alert on sustained delivery failures
- Monitor webhook processing latency
Testing Webhooks During Development
Testing webhooks locally is challenging because your development machine is not publicly accessible. These tools solve that problem:
ngrok for Local Testing
ngrok http 3000
Forwarding: https://abc123.ngrok.io -> http://localhost:3000
# Use the ngrok URL as your webhook URL in GitHub/Stripe
# https://abc123.ngrok.io/webhook/github
Stripe CLI for Local Testing
stripe listen --forward-to localhost:3000/webhook/stripe
# Trigger a test event
stripe trigger checkout.session.completed
# Trigger with specific data
stripe trigger payment_intent.succeeded
Manual Testing with curl
PAYLOAD='{"action":"push","ref":"refs/heads/main"}'
SECRET="your-webhook-secret"
SIGNATURE="sha256=$(echo -n "$PAYLOAD" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')"
curl -X POST http://localhost:3000/webhook/github \
-H "Content-Type: application/json" \
-H "X-Hub-Signature-256: $SIGNATURE" \
-H "X-GitHub-Event: push" \
-d "$PAYLOAD"
Scaling Webhook Processing
As your webhook volume grows, you need to decouple receiving from processing. The pattern is: receive the webhook, validate the signature, store the event in a queue, and respond with 200. A separate worker process then reads from the queue and handles each event at its own pace.
Receive + Verify
Store event
Process async
Failed events
A dead letter queue captures events that fail processing after multiple retries. This ensures no event is lost permanently — you can inspect failed events, fix bugs, and reprocess them.
Webhooks with Panelica
Summary
Webhooks are the backbone of modern service integrations. They replace wasteful polling with real-time push notifications, and when implemented correctly, they are reliable, secure, and efficient. The critical requirements are simple: always verify signatures, always respond quickly, always handle duplicates, and always log everything.
Start with signature verification — it is non-negotiable. Then add idempotency to handle retries safely. Finally, as your volume grows, move processing to a background queue so your webhook endpoint stays fast and responsive. With these three principles in place, you can integrate with any webhook provider confidently.