Implementing Webhooks: A Complete Guide
Implementing Webhooks: A Complete Guide
Webhooks let external services push events to your application in real time. Instead of polling an API every few seconds, you register a URL and the service sends HTTP requests when things happen. Getting webhooks right means handling security, retries, and idempotency.
How Webhooks Work
Building a Webhook Endpoint
// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers";
import Stripe from "stripe";
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;
export async function POST(request: Request) {
const body = await request.text();
const signature = headers().get("stripe-signature")!;
let event: Stripe.Event;
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret);
} catch (err) {
console.error("Signature verification failed:", err);
return new Response("Invalid signature", { status: 400 });
}
// Process the event
switch (event.type) {
case "checkout.session.completed":
await handleCheckoutComplete(event.data.object);
break;
case "invoice.payment_failed":
await handlePaymentFailed(event.data.object);
break;
}
return new Response("OK", { status: 200 });
}
Security Best Practices
Verify Signatures
Every reputable webhook provider signs payloads with HMAC. Always verify before processing:
import crypto from "crypto";
function verifyWebhookSignature(
payload: string,
signature: string,
secret: string
): boolean {
const expected = crypto
.createHmac("sha256", secret)
.update(payload)
.digest("hex");
return crypto.timingSafeEqual(
Buffer.from(signature),
Buffer.from(expected)
);
}
Use Raw Body
Parse the raw request body for signature verification. If your framework parses JSON first, the signature check will fail because whitespace and key ordering may differ.
Idempotency
Webhooks can be delivered more than once. Always make your handler idempotent:
async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
// Check if we already processed this event
const existing = await db.order.findUnique({
where: { stripeSessionId: session.id },
});
if (existing) return; // Already processed — skip
await db.order.create({
data: {
stripeSessionId: session.id,
userId: session.metadata.userId,
amount: session.amount_total,
status: "completed",
},
});
}
Designing Your Own Webhooks
When building a service that sends webhooks:
Retry Strategy
Use exponential backoff: retry at 1 min, 5 min, 30 min, 2 hours, 24 hours. After exhausting retries, mark the endpoint as failing and notify the subscriber.
Event Format
Standardize your event payload:
{
"id": "evt_abc123",
"type": "order.completed",
"created_at": "2026-06-01T12:00:00Z",
"data": {
"order_id": "ord_xyz",
"total": 9900,
"currency": "usd"
}
}
Delivery Logging
Log every delivery attempt with timestamp, response status, and response body. This is invaluable for debugging.
Testing Webhooks Locally
Use tools to forward webhook traffic to your local machine during development:
Stripe CLI
stripe listen --forward-to localhost:3000/api/webhooks/stripe
ngrok
ngrok http 3000
Conclusion
Webhooks replace polling with real-time event delivery. Verify signatures to prevent spoofing, make handlers idempotent to survive duplicate deliveries, and implement retry logic when sending your own. Log everything — webhook debugging without logs is guesswork.