What Are Webhooks? A Developer's Guide
A beginner-friendly explanation of webhooks. Learn polling vs webhooks, how they work under the hood, and build your first webhook receiver step by step.
Ozer
Developer & Founder of HookSense
Webhooks are one of those concepts that seem simple on the surface but have surprising depth once you start building with them. If you have ever connected Stripe to your app, set up a GitHub Actions trigger, or received Slack notifications from an external service, you have used webhooks — even if you did not realize it.
This guide explains webhooks from the ground up. We will cover what they are, how they differ from APIs and polling, walk through the mechanics of how they work, and build a working webhook receiver step by step.
The Simplest Explanation
A webhook is a URL on your server that another service calls when something happens.
That is it. No special protocol, no complex handshake. When an event occurs (a payment succeeds, a commit is pushed, an order is placed), the service sends an HTTP POST request to your URL with data about what happened. Your server receives the request, processes the data, and responds with a 200 OK.
Polling vs Webhooks
To understand why webhooks exist, you need to understand the alternative: polling.
Polling Approach
Imagine you are waiting for a payment to complete in Stripe. Without webhooks, you would have to repeatedly ask Stripe "has the payment completed yet?":
// Polling: ask Stripe every 5 seconds
setInterval(async () => {
const session = await stripe.checkout.sessions.retrieve(sessionId);
if (session.payment_status === 'paid') {
fulfillOrder(session);
clearInterval(this);
}
}, 5000);
This approach has serious problems:
- Wasteful: Most requests return "nothing has changed." You are consuming API quota and server resources for no reason.
- Slow: There is always a delay between when the event happens and when your next poll discovers it. A 5-second interval means up to 5 seconds of latency.
- Fragile: If your server restarts, the polling loop dies. If you poll too aggressively, you hit rate limits. If you poll too slowly, you miss time-sensitive events.
- Does not scale: If you have 1,000 active sessions, you need 1,000 polling loops, each making API calls every few seconds.
Webhook Approach
With webhooks, Stripe tells you when the payment completes:
// Webhook: Stripe calls YOUR server when payment completes
app.post('/webhooks/stripe', (req, res) => {
const event = req.body;
if (event.type === 'checkout.session.completed') {
fulfillOrder(event.data.object);
}
res.status(200).json({ received: true });
});
This is better in every way: zero wasted requests, instant notification, no polling infrastructure to maintain, and it scales to millions of events.
How Webhooks Work Under the Hood
Here is the complete lifecycle of a webhook delivery:
- Registration: You tell the service "when event X happens, send an HTTP POST to this URL." This is usually done in the service's dashboard or via API.
- Event occurs: A user completes a purchase, pushes a commit, or performs whatever action triggers the event.
- Payload construction: The service creates a JSON object describing the event, including the event type, timestamp, and relevant data.
- Signing: The service computes an HMAC signature of the payload using a shared secret, and includes it in a request header.
- Delivery: The service sends an HTTP POST request to your registered URL with the JSON payload as the body.
- Processing: Your server receives the request, verifies the signature, processes the event, and returns a 2xx status code.
- Retry (if needed): If your server returns a non-2xx status or does not respond within the timeout, the service retries with exponential backoff.
Building Your First Webhook Receiver
Let us build a simple webhook receiver in Node.js. We will use Express, but the concepts apply to any framework (Hono, Fastify, Next.js API routes, etc.).
Step 1: Basic Server
const express = require('express');
const app = express();
// IMPORTANT: Use raw body for signature verification
app.use('/webhooks', express.raw({ type: 'application/json' }));
// Parse JSON for all other routes
app.use(express.json());
app.post('/webhooks/stripe', (req, res) => {
const payload = JSON.parse(req.body);
console.log('Received webhook:', payload.type);
console.log('Data:', JSON.stringify(payload.data.object, null, 2));
// Always return 200 to acknowledge receipt
res.status(200).json({ received: true });
});
app.listen(3000, () => {
console.log('Webhook receiver running on port 3000');
});
Step 2: Add Signature Verification
const crypto = require('crypto');
function verifyStripeSignature(payload, header, secret) {
const parts = header.split(',');
const timestamp = parts.find(p => p.startsWith('t=')).split('=')[1];
const signature = parts.find(p => p.startsWith('v1=')).split('=')[1];
const signedPayload = timestamp + '.' + payload;
const expected = crypto
.createHmac('sha256', secret)
.update(signedPayload)
.digest('hex');
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
app.post('/webhooks/stripe', (req, res) => {
const sig = req.headers['stripe-signature'];
if (!verifyStripeSignature(req.body.toString(), sig, WEBHOOK_SECRET)) {
return res.status(401).send('Invalid signature');
}
const event = JSON.parse(req.body);
handleEvent(event);
res.status(200).json({ received: true });
});
Step 3: Handle Events by Type
function handleEvent(event) {
switch (event.type) {
case 'checkout.session.completed':
// Fulfill the order
const session = event.data.object;
console.log('Order completed:', session.id);
break;
case 'invoice.paid':
// Continue the subscription
const invoice = event.data.object;
console.log('Invoice paid:', invoice.id);
break;
case 'invoice.payment_failed':
// Notify the customer
const failedInvoice = event.data.object;
console.log('Payment failed:', failedInvoice.id);
break;
default:
console.log('Unhandled event type:', event.type);
}
}
Common Webhook Providers
Nearly every major service supports webhooks. Here are the most popular ones and their primary use cases:
| Provider | Common Events | Signature Header |
|---|---|---|
| Stripe | Payments, subscriptions, invoices | Stripe-Signature |
| GitHub | Pushes, PRs, issues, releases | X-Hub-Signature-256 |
| Shopify | Orders, products, customers | X-Shopify-Hmac-SHA256 |
| Twilio | SMS received, call status | X-Twilio-Signature |
| Slack | Messages, reactions, commands | X-Slack-Signature |
| SendGrid | Email delivered, bounced, opened | Event Webhook Verification |
| PayPal | Payments, disputes, subscriptions | PAYPAL-TRANSMISSION-SIG |
The Local Development Challenge
Here is a problem every developer hits: webhook providers need a publicly accessible URL, but during development, your server runs on localhost. There are several solutions:
Option 1: Tunneling (ngrok, localtunnel)
Tools like ngrok create a public URL that tunnels traffic to your local machine. This works but has drawbacks: the URL changes on restart (free tier), the connection can be flaky, and you cannot inspect or replay requests.
Option 2: Provider CLI (Stripe CLI)
Some providers offer CLI tools that forward events locally. Stripe CLI is the best example: stripe listen --forward-to localhost:3000. This works well but only supports that specific provider.
Option 3: HookSense
HookSense combines inspection and forwarding. Your webhook provider sends events to a persistent HookSense URL. You can inspect every request in the web UI, and the CLI forwards them to your local server:
npx hooksense listen -p 3000
This works with any webhook provider, gives you a visual inspector, supports replay, and verifies HMAC signatures automatically.
Webhook Best Practices
- Always verify signatures. Never skip this step, even in development. Spoofing a webhook is as easy as sending a
curlrequest. - Return 200 immediately. Acknowledge receipt before doing heavy processing. Move business logic to a background job if it takes more than a few seconds.
- Handle retries idempotently. Providers retry failed deliveries. Use the event ID to deduplicate and avoid processing the same event twice.
- Log everything. Store raw payloads before processing. When something goes wrong, you will want to see exactly what was received.
- Handle events out of order. A
payment_intent.succeededmight arrive beforecheckout.session.completed. Design your handlers to work regardless of arrival order. - Monitor your endpoints. Track response times, error rates, and retry counts. A spike in retries usually means your handler is broken.
What Can You Build with Webhooks?
Webhooks unlock real-time integrations that would be impossible or impractical with polling:
- Payment processing: Fulfill orders when Stripe confirms payment
- CI/CD pipelines: Trigger builds when code is pushed to GitHub
- Chat notifications: Post to Slack when a new issue is created
- Inventory sync: Update stock levels when a Shopify order is placed
- User onboarding: Send a welcome email when a user signs up via OAuth
- Audit logging: Record every change in an external system
- Auto-moderation: React to new content posted on a platform
Getting Started
The fastest way to start working with webhooks is to create a free HookSense endpoint, point a webhook provider at it, and watch the requests flow in. You will see the exact headers, body, and metadata of every request — and you can replay them as many times as you need while building your handler.
No credit card required, no server setup, no tunneling configuration. Just a URL and real-time inspection.
Related
Try HookSense Free
Inspect, debug, and replay webhooks in real-time. No credit card required.
Get Started Free