Skip to main content
Back to Blog
Tutorials
3 min read
November 9, 2024

How to Implement Webhooks in Your Next.js Application

Build webhook endpoints in Next.js. Signature verification, payload processing, retry logic, and event handling.

Ryel Banfield

Founder & Lead Developer

Webhooks enable real-time integrations with services like Stripe, Clerk, GitHub, and more. Here is how to build secure webhook endpoints.

Step 1: Basic Webhook Endpoint

// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";

export async function POST(req: NextRequest) {
  const body = await req.text();

  // Process the webhook payload
  const event = JSON.parse(body);
  console.log("Webhook received:", event.type);

  return NextResponse.json({ received: true });
}

Step 2: Stripe Webhook with Signature Verification

pnpm add stripe
// app/api/webhooks/stripe/route.ts
import { NextRequest, NextResponse } from "next/server";
import Stripe from "stripe";

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get("stripe-signature")!;

  let event: Stripe.Event;

  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 }
    );
  }

  // Handle the event
  switch (event.type) {
    case "checkout.session.completed": {
      const session = event.data.object as Stripe.Checkout.Session;
      await handleCheckoutComplete(session);
      break;
    }
    case "invoice.payment_succeeded": {
      const invoice = event.data.object as Stripe.Invoice;
      await handlePaymentSucceeded(invoice);
      break;
    }
    case "customer.subscription.deleted": {
      const subscription = event.data.object as Stripe.Subscription;
      await handleSubscriptionCancelled(subscription);
      break;
    }
    default:
      console.log(`Unhandled event type: ${event.type}`);
  }

  return NextResponse.json({ received: true });
}

async function handleCheckoutComplete(session: Stripe.Checkout.Session) {
  // Update user subscription in database
  // Send welcome email
}

async function handlePaymentSucceeded(invoice: Stripe.Invoice) {
  // Record payment
  // Update billing history
}

async function handleSubscriptionCancelled(sub: Stripe.Subscription) {
  // Downgrade user access
  // Send cancellation email
}

Step 3: Clerk Webhook

pnpm add svix
// app/api/webhooks/clerk/route.ts
import { NextRequest, NextResponse } from "next/server";
import { Webhook } from "svix";

const webhookSecret = process.env.CLERK_WEBHOOK_SECRET!;

export async function POST(req: NextRequest) {
  const body = await req.text();
  const svixId = req.headers.get("svix-id")!;
  const svixTimestamp = req.headers.get("svix-timestamp")!;
  const svixSignature = req.headers.get("svix-signature")!;

  const wh = new Webhook(webhookSecret);

  let event: any;
  try {
    event = wh.verify(body, {
      "svix-id": svixId,
      "svix-timestamp": svixTimestamp,
      "svix-signature": svixSignature,
    });
  } catch {
    return NextResponse.json({ error: "Invalid signature" }, { status: 400 });
  }

  switch (event.type) {
    case "user.created":
      await createUserInDatabase(event.data);
      break;
    case "user.updated":
      await updateUserInDatabase(event.data);
      break;
    case "user.deleted":
      await deleteUserFromDatabase(event.data.id);
      break;
  }

  return NextResponse.json({ received: true });
}

Step 4: Generic Webhook Signature Verification

For services using HMAC-SHA256 signatures:

// lib/webhook.ts
import { createHmac, timingSafeEqual } from "crypto";

export function verifyWebhookSignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const expectedSignature = createHmac("sha256", secret)
    .update(payload)
    .digest("hex");

  try {
    return timingSafeEqual(
      Buffer.from(signature),
      Buffer.from(expectedSignature)
    );
  } catch {
    return false;
  }
}
// Usage in route handler
export async function POST(req: NextRequest) {
  const body = await req.text();
  const signature = req.headers.get("x-webhook-signature")!;

  if (!verifyWebhookSignature(body, signature, process.env.WEBHOOK_SECRET!)) {
    return NextResponse.json({ error: "Invalid signature" }, { status: 401 });
  }

  const event = JSON.parse(body);
  // Process event...
  return NextResponse.json({ received: true });
}

Step 5: Idempotent Processing

Webhooks can be retried. Ensure idempotent handling:

export async function POST(req: NextRequest) {
  const body = await req.text();
  const event = JSON.parse(body);
  const eventId = event.id;

  // Check if already processed
  const existing = await db
    .select()
    .from(processedEvents)
    .where(eq(processedEvents.eventId, eventId))
    .limit(1);

  if (existing.length > 0) {
    return NextResponse.json({ received: true, duplicate: true });
  }

  // Process the event
  await processEvent(event);

  // Mark as processed
  await db.insert(processedEvents).values({
    eventId,
    processedAt: new Date(),
  });

  return NextResponse.json({ received: true });
}

Step 6: Sending Webhooks from Your App

// lib/webhooks.ts
import { createHmac } from "crypto";

interface WebhookPayload {
  event: string;
  data: Record<string, unknown>;
  timestamp: string;
}

export async function sendWebhook(
  url: string,
  secret: string,
  payload: WebhookPayload
) {
  const body = JSON.stringify(payload);
  const signature = createHmac("sha256", secret).update(body).digest("hex");

  const response = await fetch(url, {
    method: "POST",
    headers: {
      "Content-Type": "application/json",
      "X-Webhook-Signature": signature,
      "X-Webhook-Timestamp": payload.timestamp,
    },
    body,
  });

  if (!response.ok) {
    throw new Error(`Webhook delivery failed: ${response.status}`);
  }

  return response;
}

Step 7: Retry Logic for Outgoing Webhooks

async function sendWithRetry(
  url: string,
  secret: string,
  payload: WebhookPayload,
  maxRetries = 3
) {
  let lastError: Error | null = null;

  for (let attempt = 0; attempt <= maxRetries; attempt++) {
    try {
      return await sendWebhook(url, secret, payload);
    } catch (err) {
      lastError = err as Error;
      if (attempt < maxRetries) {
        // Exponential backoff: 1s, 2s, 4s
        const delay = Math.pow(2, attempt) * 1000;
        await new Promise((resolve) => setTimeout(resolve, delay));
      }
    }
  }

  throw lastError;
}

Step 8: Testing Webhooks Locally

Use the Stripe CLI or ngrok to test locally:

# Stripe CLI
stripe listen --forward-to localhost:3000/api/webhooks/stripe

# ngrok
ngrok http 3000
# Use the ngrok URL as the webhook endpoint

Need Webhook Integrations?

We build web applications with robust webhook systems, third-party integrations, and real-time event processing. Contact us to get started.

webhooksAPINext.jsintegrationtutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles