What Is a Webhook?
A webhook is an HTTP callback — another service sends a POST request to your URL when an event occurs. Instead of asking "did anything change?" repeatedly (polling), the service notifies you immediately.
Polling: Your app → API every 60s: "New payments?" API: "No... No... Yes!"
Webhook: Payment happens → Stripe immediately POSTs to your-app.com/webhooks
Webhooks are real-time, efficient (no wasted requests), and simpler to scale than polling.
How Webhooks Work
- You register a URL with the service:
https://your-app.com/webhooks/stripe - Event occurs (payment, user signup, etc.)
- Service POSTs a JSON payload to your URL
- Your server processes the event and returns HTTP 200
- If you don't return 2xx, the service retries (exponential backoff)
Receiving Webhooks (Express)
// IMPORTANT: use express.raw() not express.json() — need raw body for signature verification
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
// Step 1: Verify signature FIRST
let event;
try {
event = stripe.webhooks.constructEvent(
req.body,
req.headers['stripe-signature'],
process.env.STRIPE_WEBHOOK_SECRET
);
} catch (err) {
return res.status(400).send('Signature verification failed');
}
// Step 2: Acknowledge immediately
res.json({ received: true });
// Step 3: Process asynchronously (after response)
setImmediate(() => processEvent(event));
});
Verifying Signatures Manually
const crypto = require('crypto');
function verifyWebhook(payload, signature, secret) {
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
const sigBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expected, 'hex');
if (sigBuffer.length !== expectedBuffer.length) return false;
return crypto.timingSafeEqual(sigBuffer, expectedBuffer); // prevent timing attacks
}
// GitHub: 'X-Hub-Signature-256' header is 'sha256=...'
app.post('/webhooks/github', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['x-hub-signature-256'].replace('sha256=', '');
if (!verifyWebhook(req.body, sig, process.env.GITHUB_SECRET)) {
return res.status(401).send('Unauthorized');
}
// process...
});
Handling Duplicate Events (Idempotency)
Services retry on failure — your handler may receive the same event multiple times. Always deduplicate:
async function processPayment(paymentIntent) {
// Check if already processed
const existing = await db.query(
'SELECT id FROM orders WHERE payment_id = $1',
[paymentIntent.id]
);
if (existing.rowCount > 0) return; // Already handled, skip
// Process for the first time
await db.query('INSERT INTO orders ...', [paymentIntent.id, ...]);
}
Respond Quickly, Process Later
Webhook services timeout in 5-30 seconds. For slow processing, acknowledge first and queue the work:
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), async (req, res) => {
const event = verifyAndParse(req);
await queue.add('process-webhook', { event }); // push to job queue
res.json({ received: true }); // respond immediately
});
Testing Locally
# Expose localhost with ngrok
ngrok http 3000
# → https://abc123.ngrok.io/webhooks/stripe
# Stripe CLI: forward real events to local server
stripe listen --forward-to localhost:3000/webhooks/stripe
stripe trigger payment_intent.succeeded
Signature Header Reference
| Service | Header | Algorithm |
|---|---|---|
| Stripe | Stripe-Signature |
HMAC-SHA256 + timestamp |
| GitHub | X-Hub-Signature-256 |
HMAC-SHA256 |
| Shopify | X-Shopify-Hmac-Sha256 |
HMAC-SHA256 (base64) |
| Slack | X-Slack-Signature |
HMAC-SHA256 + timestamp |
→ Generate and verify HMAC signatures with the HMAC Generator.