Webhooks Guide
Event-driven push notifications for REST APIs — how webhooks work, HMAC signature verification, delivery guarantees, and production-ready implementation patterns.
What Are Webhooks?
A webhook is an HTTP callback — a POST request that a server sends to a client-configured URL when a specific event occurs. Instead of polling an API repeatedly to check for changes, your application registers a URL and receives event notifications in real time.
Pull vs Push: Polling vs Webhooks
| Aspect | Polling (Pull) | Webhooks (Push) |
|---|---|---|
| Latency | Depends on polling interval | Near real-time |
| Efficiency | Many wasted requests | Only sends when events occur |
| Server load | Higher — constant requests | Lower — event-driven |
| Client complexity | Low — just a loop | Higher — must handle POST requests |
| Reliability | Client controls retries | Provider must implement retries |
| Firewall-friendly | Yes | Requires public endpoint |
Common Webhook Use Cases
- Payments: Stripe sends
payment_intent.succeeded,invoice.paid,charge.refunded - Version control: GitHub sends
push,pull_request,releaseevents - E-commerce: Shopify sends
orders/create,fulfillments/create - Communications: Twilio sends message delivery status callbacks
- CI/CD: Build systems notify on deployment success or failure
Webhook Payload Structure
A webhook payload is a standard HTTP POST request with a JSON body. Well-designed webhooks include an event type, a unique event ID, a timestamp, and the event data.
POST /webhooks/stripe HTTP/1.1
Host: api.yourapp.com
Content-Type: application/json
Stripe-Signature: t=1714000000,v1=abc123...
{
"id": "evt_3OxZKL2eZvKYlo2C1234567",
"type": "payment_intent.succeeded",
"created": 1714000000,
"livemode": true,
"data": {
"object": {
"id": "pi_3OxZKL2eZvKYlo2C1234567",
"amount": 9900,
"currency": "usd",
"status": "succeeded",
"metadata": {
"order_id": "order_abc123"
}
}
}
}
Key Fields
- Event ID: Unique identifier for the event — use this for deduplication
- Event type: Namespaced string like
payment_intent.succeeded— allows filtering - Created timestamp: Unix timestamp for ordering and replay
- Data object: The current state of the resource that triggered the event
HMAC Signature Verification
Without signature verification, anyone who discovers your webhook URL can send fake events. Webhook providers sign payloads using HMAC-SHA256 with a shared secret that you configure in your provider's dashboard.
How HMAC Signing Works
# Provider (sender) generates signature:
secret = "whsec_your_webhook_secret"
payload = request_body # raw bytes, before any parsing
signature = HMAC-SHA256(key=secret, message=payload)
# Sends in header:
X-Hub-Signature-256: sha256=abc123def456...
# or for Stripe:
Stripe-Signature: t=1714000000,v1=abc123def456...
Node.js: Verifying Stripe Webhooks
const express = require('express');
const crypto = require('crypto');
const app = express();
// IMPORTANT: Use raw body parser — signature covers raw bytes
app.post('/webhooks/stripe', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
const secret = process.env.STRIPE_WEBHOOK_SECRET;
// Stripe signature format: t=timestamp,v1=signature
const parts = Object.fromEntries(sig.split(',').map(p => p.split('=')));
const timestamp = parts.t;
const receivedSig = parts.v1;
// Compute expected signature
const signedPayload = timestamp + '.' + req.body.toString();
const expectedSig = crypto
.createHmac('sha256', secret)
.update(signedPayload, 'utf8')
.digest('hex');
// Use constant-time comparison to prevent timing attacks
const sigBuffer = Buffer.from(receivedSig, 'hex');
const expectedBuffer = Buffer.from(expectedSig, 'hex');
if (sigBuffer.length !== expectedBuffer.length ||
!crypto.timingSafeEqual(sigBuffer, expectedBuffer)) {
console.error('Invalid webhook signature');
return res.status(400).json({ error: 'Invalid signature' });
}
// Check timestamp to prevent replay attacks (reject events older than 5 minutes)
const eventTime = parseInt(timestamp, 10);
const now = Math.floor(Date.now() / 1000);
if (Math.abs(now - eventTime) > 300) {
return res.status(400).json({ error: 'Webhook timestamp too old' });
}
const event = JSON.parse(req.body);
handleWebhookEvent(event);
res.status(200).json({ received: true });
});
Generic HMAC Verification
function verifyWebhookSignature(rawBody, signature, secret) {
// signature may be in format "sha256=abc123"
const hash = signature.startsWith('sha256=')
? signature.slice(7)
: signature;
const expectedHash = crypto
.createHmac('sha256', secret)
.update(rawBody)
.digest('hex');
// Constant-time comparison
return crypto.timingSafeEqual(
Buffer.from(hash, 'hex'),
Buffer.from(expectedHash, 'hex')
);
}
Delivery, Retries, and Idempotency
Respond Quickly — Defer Heavy Work
Webhook providers expect a response within a short timeout — typically 5–30 seconds (Stripe: 30s, GitHub: 10s). If your endpoint exceeds the timeout, the provider treats the delivery as failed and schedules a retry.
Best practice: Acknowledge the webhook immediately with a 200 OK, then process the event asynchronously via a job queue (Bull, Celery, SQS, etc.).
app.post('/webhooks', async (req, res) => {
// Verify signature first
if (!verifyWebhookSignature(req.rawBody, req.headers['x-signature'], secret)) {
return res.status(400).send('Invalid signature');
}
// Acknowledge immediately
res.status(200).json({ received: true });
// Enqueue for async processing (don't await)
await queue.add('process-webhook', req.body);
});
Retry Strategies
Most webhook providers retry on non-2xx responses or timeouts, using exponential backoff:
| Provider | Retry Schedule | Max Retries |
|---|---|---|
| Stripe | Immediately, 5 min, 30 min, 2 hr, 5 hr, 10 hr, 24 hr... | ~72 hours total |
| GitHub | Immediately, then not retried automatically | Manual redeliver available |
| Shopify | 19 retries over 48 hours | 19 |
| SendGrid | Exponential backoff, up to 3 days | ~72 hours total |
Idempotent Event Processing
Because webhooks can be delivered more than once (due to retries or network issues), your event handler must be idempotent. Use the event ID to deduplicate:
async function handleWebhookEvent(event) {
// Check if we've already processed this event
const processed = await db.webhookEvents.findOne({ eventId: event.id });
if (processed) {
console.log('Duplicate event, skipping:', event.id);
return;
}
// Process the event
switch (event.type) {
case 'payment_intent.succeeded':
await fulfillOrder(event.data.object.metadata.order_id);
break;
case 'invoice.payment_failed':
await sendPaymentFailureEmail(event.data.object.customer);
break;
default:
console.log('Unhandled event type:', event.type);
}
// Mark as processed
await db.webhookEvents.insert({
eventId: event.id,
type: event.type,
processedAt: new Date()
});
}
Registering Webhooks via REST API
If your platform provides webhooks to customers, you'll need a REST API for managing webhook registrations. Standard patterns:
Webhook Registration Endpoints
# Register a new webhook
POST /webhooks
{
"url": "https://app.customer.com/webhooks/payments",
"events": ["payment.succeeded", "payment.failed", "refund.created"],
"secret": "auto-generated-by-server"
}
→ 201 Created
{
"id": "wh_abc123",
"url": "https://app.customer.com/webhooks/payments",
"events": ["payment.succeeded", "payment.failed", "refund.created"],
"secret": "whsec_Km9pLqR2xYvNzBdTjW...", # Show once; store securely
"active": true,
"created_at": "2026-04-22T10:00:00Z"
}
# List webhooks
GET /webhooks
# Update events or URL
PATCH /webhooks/wh_abc123
{ "events": ["payment.succeeded"] }
# Delete webhook
DELETE /webhooks/wh_abc123
# Test webhook (send a test event)
POST /webhooks/wh_abc123/test
{ "event_type": "payment.succeeded" }
Security Best Practices for Webhook Registration
- Generate the signing secret on your server — never let the client provide it
- Return the secret only on creation — never include it in subsequent GET responses
- Allow customers to rotate secrets without downtime (support both old and new during a transition window)
- Validate that the target URL is HTTPS and reachable before accepting the registration
- Rate limit webhook deliveries per customer to prevent self-DDoS
Testing Webhooks Locally
Webhooks need a public URL to receive requests. For local development, use a tunneling tool to expose your localhost:
| Tool | Command | Notes |
|---|---|---|
| ngrok | ngrok http 3000 |
Most popular; free tier available; static domain on paid plan |
| Stripe CLI | stripe listen --forward-to localhost:3000/webhooks |
Built-in Stripe webhook forwarding and replay |
| localtunnel | npx localtunnel --port 3000 |
Open source; free; ephemeral URLs |
| webhook.site | Browser-based | No local server needed; inspect incoming webhook payloads |
Replaying Failed Webhooks
In production, keep a log of all incoming webhooks (event ID, payload, processing status) so you can replay failed events. Many providers offer a dashboard to manually redeliver events, but having your own replay mechanism is more reliable:
# Re-process a stored webhook event
async function replayEvent(eventId) {
const event = await db.webhookEvents.findOne({ eventId, status: 'failed' });
if (!event) throw new Error('Event not found');
await handleWebhookEvent(event.payload);
await db.webhookEvents.update(
{ eventId },
{ status: 'processed', replayedAt: new Date() }
);
}
Frequently Asked Questions
What happens if my webhook endpoint is down?
The provider will retry the delivery according to their schedule. Ensure your endpoint returns quickly and your infrastructure has high availability. If you're in a maintenance window, most providers allow you to temporarily disable webhook deliveries or catch up via event log APIs (Stripe events API, GitHub delivery logs).
Should I process webhooks synchronously or asynchronously?
Asynchronously, always. Return 200 OK immediately and enqueue the event for background processing. Synchronous processing risks timeouts (causing retries and duplicate deliveries) and couples your webhook availability to your database/service availability.
How do I handle webhook events arriving out of order?
This is a real problem in distributed systems. An order.cancelled event may arrive before order.created due to network delays. Strategies:
- Always fetch the current resource state from the API after receiving a webhook, rather than trusting the payload alone
- Use the event's
createdtimestamp to detect and discard stale events - Design your state machine to be tolerant of out-of-order transitions
What is the difference between webhooks and WebSockets?
Webhooks are one-way server-to-server HTTP callbacks — your server receives an event when something happens. No persistent connection. Works over standard HTTPS. WebSockets provide a persistent, bidirectional connection between client and server, enabling real-time two-way communication. Use webhooks for server-to-server event notification; use WebSockets for real-time browser UIs (chat, live dashboards).
Related Topics
Authentication
HMAC, JWT, OAuth 2.0, and API key security patterns.
Idempotency
Idempotency keys and safe retry patterns for reliable APIs.
Security
HTTPS, CORS, input validation, and API security best practices.
Error Handling
Consistent error formats and guidance for retry decisions.
Rate Limiting
Throttling strategies and exponential backoff for retries.
Real-World Examples
Full API examples across e-commerce, social, and SaaS use cases.