Receive real-time notifications from external services like Stripe, calendars, and third-party APIs. Process them securely and reliably.
External Service (Stripe, Calendar, etc.)
│
│ POST /api/webhooks/stripe
▼
┌───────────────────────────────────────┐
│ 1. SIGNATURE VERIFICATION │
│ - Verify HMAC signature │
│ - Check timestamp (replay attack) │
│ - Validate webhook source │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 2. IDEMPOTENCY CHECK │
│ - Check if event already processed│
│ - Store event ID in database │
│ - Return 200 if duplicate │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 3. QUICK ACKNOWLEDGMENT │
│ - Return 200 immediately │
│ - Queue event for async processing│
│ - Avoid timeout issues │
└───────────────────┬───────────────────┘
│
▼
┌───────────────────────────────────────┐
│ 4. ASYNC PROCESSING (Inngest) │
│ - Parse and validate payload │
│ - Execute business logic │
│ - Handle errors with retries │
└───────────────────────────────────────┘// app/api/webhooks/stripe/route.ts
import { headers } from "next/headers"
import { NextResponse } from "next/server"
import Stripe from "stripe"
import { inngest } from "@/lib/inngest/client"
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 headersList = await headers()
const signature = headersList.get("stripe-signature")!
let event: Stripe.Event
// 1. Verify signature
try {
event = stripe.webhooks.constructEvent(body, signature, webhookSecret)
} catch (err) {
console.error("Webhook signature verification failed:", err)
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
)
}
// 2. Quick acknowledgment - send to Inngest for async processing
try {
await inngest.send({
name: "stripe.webhook.received",
data: {
eventId: event.id,
eventType: event.type,
payload: event.data.object,
created: event.created
}
})
return NextResponse.json({ received: true })
} catch (err) {
console.error("Failed to queue webhook:", err)
return NextResponse.json(
{ error: "Failed to process" },
{ status: 500 }
)
}
}
// Inngest function to process Stripe webhooks
export const processStripeWebhook = inngest.createFunction(
{
id: "process-stripe-webhook",
retries: 3
},
{ event: "stripe.webhook.received" },
async ({ event, step }) => {
const { eventType, payload, eventId } = event.data
// Check idempotency
const alreadyProcessed = await step.run("check-idempotency", async () => {
return await checkWebhookProcessed(eventId)
})
if (alreadyProcessed) {
return { status: "skipped", reason: "duplicate" }
}
// Route to appropriate handler
switch (eventType) {
case "payment_intent.succeeded":
await step.run("handle-payment-success", async () => {
await handlePaymentSuccess(payload as Stripe.PaymentIntent)
})
break
case "payment_intent.payment_failed":
await step.run("handle-payment-failure", async () => {
await handlePaymentFailure(payload as Stripe.PaymentIntent)
})
break
case "customer.subscription.updated":
await step.run("handle-subscription-update", async () => {
await handleSubscriptionUpdate(payload as Stripe.Subscription)
})
break
case "invoice.payment_failed":
await step.run("handle-invoice-failure", async () => {
await handleInvoiceFailure(payload as Stripe.Invoice)
})
break
default:
console.log(`Unhandled event type: ${eventType}`)
}
// Mark as processed
await step.run("mark-processed", async () => {
await markWebhookProcessed(eventId)
})
return { status: "processed", eventType }
}
)Always verify webhook signatures to ensure the request came from the expected source.
// lib/webhooks/verify.ts
import crypto from "crypto"
interface VerifyOptions {
payload: string
signature: string
secret: string
timestamp?: string
tolerance?: number // seconds
}
export function verifyWebhookSignature({
payload,
signature,
secret,
timestamp,
tolerance = 300 // 5 minutes
}: VerifyOptions): boolean {
// Check timestamp to prevent replay attacks
if (timestamp) {
const webhookTime = parseInt(timestamp, 10)
const currentTime = Math.floor(Date.now() / 1000)
if (Math.abs(currentTime - webhookTime) > tolerance) {
throw new Error("Webhook timestamp too old")
}
}
// Compute expected signature
const signedPayload = timestamp
? `${timestamp}.${payload}`
: payload
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(signedPayload)
.digest("hex")
// Constant-time comparison to prevent timing attacks
const sigBuffer = Buffer.from(signature)
const expectedBuffer = Buffer.from(expectedSignature)
if (sigBuffer.length !== expectedBuffer.length) {
return false
}
return crypto.timingSafeEqual(sigBuffer, expectedBuffer)
}
// Generic webhook handler with verification
export async function handleWebhook(
request: Request,
config: {
secretEnvVar: string
signatureHeader: string
timestampHeader?: string
}
) {
const body = await request.text()
const headersList = await headers()
const signature = headersList.get(config.signatureHeader)
const timestamp = config.timestampHeader
? headersList.get(config.timestampHeader)
: undefined
if (!signature) {
throw new Error("Missing signature header")
}
const isValid = verifyWebhookSignature({
payload: body,
signature,
secret: process.env[config.secretEnvVar]!,
timestamp: timestamp || undefined
})
if (!isValid) {
throw new Error("Invalid webhook signature")
}
return JSON.parse(body)
}Webhooks may be delivered multiple times. Always implement idempotency checks.
// lib/webhooks/idempotency.ts
import { sql } from "@/lib/db"
interface WebhookRecord {
eventId: string
source: string
processedAt: Date
result: string
}
export async function checkWebhookProcessed(
eventId: string,
source: string = "unknown"
): Promise<boolean> {
const result = await sql`
SELECT event_id FROM processed_webhooks
WHERE event_id = ${eventId} AND source = ${source}
`
return result.length > 0
}
export async function markWebhookProcessed(
eventId: string,
source: string,
result: object = {}
): Promise<void> {
await sql`
INSERT INTO processed_webhooks (event_id, source, result, processed_at)
VALUES (${eventId}, ${source}, ${JSON.stringify(result)}, NOW())
ON CONFLICT (event_id, source) DO NOTHING
`
}
// With Redis for faster lookups
import { Redis } from "@upstash/redis"
const redis = new Redis({
url: process.env.KV_REST_API_URL!,
token: process.env.KV_REST_API_TOKEN!
})
export async function checkWebhookProcessedRedis(
eventId: string
): Promise<boolean> {
const key = `webhook:processed:${eventId}`
const exists = await redis.exists(key)
return exists === 1
}
export async function markWebhookProcessedRedis(
eventId: string,
ttlSeconds: number = 86400 * 7 // 7 days
): Promise<void> {
const key = `webhook:processed:${eventId}`
await redis.set(key, "1", { ex: ttlSeconds })
}Payment events: payment_intent.succeeded, invoice.paid, subscription.updated
stripe-signature header + STRIPE_WEBHOOK_SECRETDelivery events: email.sent, email.delivered, email.bounced, email.opened
svix-signature header + RESEND_WEBHOOK_SECRETSync events: calendar.updated, event.created, event.cancelled
Channel token verification + push notificationMessage events: message.sent, message.delivered, call.completed
X-Twilio-Signature header + TWILIO_AUTH_TOKEN// Webhook endpoint with proper error responses
export async function POST(request: Request) {
try {
// Verify and parse
const event = await verifyAndParseWebhook(request)
// Queue for processing (quick ack)
await inngest.send({
name: "webhook.received",
data: event
})
// Return 200 immediately
return NextResponse.json({ received: true })
} catch (error) {
if (error instanceof SignatureVerificationError) {
// 400 - Don't retry, signature is invalid
return NextResponse.json(
{ error: "Invalid signature" },
{ status: 400 }
)
}
if (error instanceof PayloadParseError) {
// 400 - Don't retry, payload is malformed
return NextResponse.json(
{ error: "Invalid payload" },
{ status: 400 }
)
}
// 500 - External service will retry
console.error("Webhook processing error:", error)
return NextResponse.json(
{ error: "Internal error" },
{ status: 500 }
)
}
}
// Response codes and their meaning:
// 200-299: Success, don't retry
// 400-499: Client error, don't retry (except 429)
// 429: Rate limited, retry with backoff
// 500-599: Server error, retryAlways return 200 quickly after queueing the webhook for async processing. Most webhook providers have short timeouts (5-30 seconds) and will retry on timeout.