TL;DR
Verify the signature before you read the payload, using HMAC and a constant-time comparison. Then return 200 fast and do the actual work in the background, in a handler that can run twice without charging anyone twice. An unverified webhook endpoint is just a public API that does whatever the caller says.
"An unverified webhook is an open door. Anyone can forge a request to your endpoint. Always verify the signature before trusting the payload."
Best Practice 1: Verify Webhook Signatures 5 min
Your webhook URL is public, and anyone who finds it can POST to it. The signature is the only thing separating a real Stripe event from a forged one:
import Stripe from 'stripe';
import { buffer } from 'micro';
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
export async function POST(req) {
const body = await buffer(req);
const signature = req.headers.get('stripe-signature');
let event;
try {
// Stripe library handles signature verification
event = stripe.webhooks.constructEvent(
body,
signature,
webhookSecret
);
} catch (err) {
console.error('Webhook signature verification failed:', err.message);
return new Response('Invalid signature', { status: 400 });
}
// Process verified event
switch (event.type) {
case 'payment_intent.succeeded':
await handlePaymentSuccess(event.data.object);
break;
case 'customer.subscription.deleted':
await handleSubscriptionCanceled(event.data.object);
break;
}
return new Response('OK', { status: 200 });
}
import crypto from 'crypto';
function verifyWebhookSignature(payload, signature, secret) {
const expectedSignature = crypto
.createHmac('sha256', secret)
.update(payload, 'utf8')
.digest('hex');
// Use constant-time comparison to prevent timing attacks
const signatureBuffer = Buffer.from(signature, 'hex');
const expectedBuffer = Buffer.from(expectedSignature, 'hex');
if (signatureBuffer.length !== expectedBuffer.length) {
return false;
}
return crypto.timingSafeEqual(signatureBuffer, expectedBuffer);
}
// Usage
app.post('/webhooks/provider', express.raw({ type: 'application/json' }), (req, res) => {
const signature = req.headers['x-webhook-signature'];
const payload = req.body.toString();
if (!verifyWebhookSignature(payload, signature, process.env.WEBHOOK_SECRET)) {
return res.status(401).json({ error: 'Invalid signature' });
}
// Process webhook...
res.status(200).send('OK');
});
Best Practice 2: Process Asynchronously 4 min
Providers give you seconds, not minutes. Stripe retries anything that doesn't answer in time, so slow handlers turn into duplicate deliveries:
import { Queue } from 'bullmq';
const webhookQueue = new Queue('webhooks');
app.post('/webhooks/stripe', async (req, res) => {
// 1. Verify signature first
const event = verifyStripeSignature(req);
if (!event) {
return res.status(400).send('Invalid signature');
}
// 2. Store event for idempotency check
const exists = await db.webhookEvent.findUnique({
where: { eventId: event.id },
});
if (exists) {
// Already processed, return success
return res.status(200).send('Already processed');
}
// 3. Queue for async processing
await webhookQueue.add('process-webhook', {
eventId: event.id,
type: event.type,
data: event.data,
});
// 4. Return quickly (within 5-10 seconds)
return res.status(200).send('Accepted');
});
// Worker processes webhooks
const worker = new Worker('webhooks', async (job) => {
const { eventId, type, data } = job.data;
try {
// Process the webhook
await processWebhookEvent(type, data);
// Mark as processed
await db.webhookEvent.create({
data: {
eventId,
type,
processedAt: new Date(),
status: 'completed',
},
});
} catch (error) {
// Mark as failed for retry
await db.webhookEvent.upsert({
where: { eventId },
create: { eventId, type, status: 'failed', error: error.message },
update: { status: 'failed', error: error.message },
});
throw error; // BullMQ will retry
}
});
Best Practice 3: Handle Idempotency 3 min
Assume every webhook arrives at least twice. Retries, network timeouts, and provider replays all produce duplicates, and a handler that charges a card is the wrong place to find that out:
| Scenario | Risk | Solution |
|---|---|---|
| Duplicate delivery | Double processing | Track event IDs |
| Out-of-order delivery | Incorrect state | Check timestamps |
| Retry after timeout | Duplicate actions | Return 200 early |
async function processPaymentWebhook(event) {
const paymentId = event.data.object.id;
// Use database transaction with unique constraint
try {
await db.$transaction(async (tx) => {
// Check if already processed
const existing = await tx.payment.findUnique({
where: { stripePaymentId: paymentId },
});
if (existing?.status === 'completed') {
console.log(`Payment ${paymentId} already processed, skipping`);
return;
}
// Process payment
await tx.payment.upsert({
where: { stripePaymentId: paymentId },
create: {
stripePaymentId: paymentId,
amount: event.data.object.amount,
status: 'completed',
processedAt: new Date(),
},
update: {
status: 'completed',
processedAt: new Date(),
},
});
// Fulfill order (only if payment newly completed)
if (!existing || existing.status !== 'completed') {
await fulfillOrder(event.data.object.metadata.orderId);
}
});
} catch (error) {
if (error.code === 'P2002') {
// Unique constraint violation - already processed
console.log('Duplicate webhook, ignoring');
return;
}
throw error;
}
}
Best Practice 4: Validate Webhook Data 4 min
A valid signature proves the message came from the provider. It says nothing about whether the amounts and IDs inside match your records:
import { z } from 'zod';
// Define expected webhook schema
const stripePaymentSchema = z.object({
id: z.string().startsWith('evt_'),
type: z.literal('payment_intent.succeeded'),
data: z.object({
object: z.object({
id: z.string().startsWith('pi_'),
amount: z.number().positive(),
currency: z.string().length(3),
customer: z.string().startsWith('cus_').optional(),
metadata: z.object({
orderId: z.string().uuid(),
}),
}),
}),
});
async function handlePaymentWebhook(event) {
// Validate webhook structure
const result = stripePaymentSchema.safeParse(event);
if (!result.success) {
console.error('Invalid webhook structure:', result.error);
throw new Error('Invalid webhook data');
}
const payment = result.data.data.object;
// IMPORTANT: Fetch fresh data from Stripe to verify
// Do not trust amounts/status from webhook alone
const stripePayment = await stripe.paymentIntents.retrieve(payment.id);
if (stripePayment.status !== 'succeeded') {
console.error('Payment not actually succeeded');
throw new Error('Payment status mismatch');
}
// Verify amount matches your records
const order = await db.order.findUnique({
where: { id: payment.metadata.orderId },
});
if (order.expectedAmount !== stripePayment.amount) {
console.error('Amount mismatch - possible tampering');
throw new Error('Amount verification failed');
}
// Safe to process
await fulfillOrder(order.id);
}
Best Practice 5: Secure Webhook Endpoints 3 min
The endpoint itself deserves the same care as any other public route:
- Use HTTPS only for webhook endpoints
- Add rate limiting to prevent abuse
- Use unpredictable URLs (include a random token in the path)
- Log all webhook requests for auditing
- Set appropriate timeouts
import rateLimit from 'express-rate-limit';
// Rate limit webhooks
const webhookLimiter = rateLimit({
windowMs: 60 * 1000, // 1 minute
max: 100, // 100 requests per minute
message: 'Too many webhook requests',
});
// Use unpredictable endpoint URL
// Bad: /webhooks/stripe
// Good: /webhooks/stripe/wh_a8f3k2j4m9x7
const WEBHOOK_TOKEN = process.env.WEBHOOK_URL_TOKEN;
app.post(
`/webhooks/stripe/${WEBHOOK_TOKEN}`,
webhookLimiter,
express.raw({ type: 'application/json' }),
async (req, res) => {
// Log for auditing
console.log('Webhook received', {
ip: req.ip,
userAgent: req.headers['user-agent'],
timestamp: new Date().toISOString(),
});
// Process webhook...
}
);
// Reject requests to wrong paths
app.post('/webhooks/*', (req, res) => {
res.status(404).send('Not found');
});
Best Practice 6: Handle Failures Gracefully 4 min
Your own failures need a plan too. A handler that throws and loses the event is worse than one that fails loudly:
// BullMQ retry configuration
const webhookQueue = new Queue('webhooks', {
defaultJobOptions: {
attempts: 5,
backoff: {
type: 'exponential',
delay: 1000, // 1s, 2s, 4s, 8s, 16s
},
removeOnComplete: 1000,
removeOnFail: 5000,
},
});
// Dead letter queue for failed webhooks
const worker = new Worker('webhooks', processWebhook, {
connection: redis,
});
worker.on('failed', async (job, err) => {
if (job.attemptsMade >= job.opts.attempts) {
// Move to dead letter queue
await deadLetterQueue.add('failed-webhook', {
originalJob: job.data,
error: err.message,
failedAt: new Date(),
});
// Alert team
await sendAlert({
severity: 'high',
message: `Webhook processing failed after ${job.attemptsMade} attempts`,
eventId: job.data.eventId,
error: err.message,
});
}
});
External Resources: For more on webhook security, see the Stripe Webhooks Best Practices , GitHub Webhook Signature Verification , and the OWASP Input Validation Cheat Sheet for industry-standard guidance.
What if a provider does not sign webhooks?
Allowlist their IPs if they publish ranges, and put a long random token in the URL path. For anything that moves money, don't trust the payload at all: call the provider's API and read the real state back. And ask whether you need the webhook, because polling is less convenient and much harder to forge.
How long should I keep webhook logs?
Thirty days minimum for debugging and disputes. Payments and subscriptions push that out much further, anywhere from 90 days to seven years depending on what you're subject to. Log the event id, type, timestamp and outcome rather than the whole payload, so you aren't retaining customer PII you never needed.
Should I use raw body or parsed JSON?
Raw body for the signature check, always. Parsing rewrites whitespace and key order, and the hash you compute afterwards won't match the one the provider sent. Parse only once the signature has passed. Most frameworks need explicit configuration to hand you the raw body, which is exactly the step people skip.
Further Reading
Put these practices into action with our step-by-step guides.
Check Your Webhook Security
Scan for webhook vulnerabilities and misconfigurations.