Skip to main content
Back to Blog
Tutorials
5 min read
November 26, 2024

How to Build a Webhook Management Dashboard in Next.js

Create a webhook management system with endpoint registration, event delivery, retry logic, and delivery logs in Next.js.

Ryel Banfield

Founder & Lead Developer

A webhook system lets external services receive real-time event notifications from your app. Here is how to build a complete webhook management system.

Step 1: Database Schema

// db/schema.ts
import { pgTable, text, timestamp, uuid, integer, boolean, jsonb } from "drizzle-orm/pg-core";

export const webhookEndpoints = pgTable("webhook_endpoints", {
  id: uuid("id").defaultRandom().primaryKey(),
  url: text("url").notNull(),
  secret: text("secret").notNull(),
  description: text("description"),
  events: text("events").array().notNull(), // ["order.created", "order.updated"]
  active: boolean("active").default(true),
  createdAt: timestamp("created_at").defaultNow(),
  updatedAt: timestamp("updated_at").defaultNow(),
});

export const webhookDeliveries = pgTable("webhook_deliveries", {
  id: uuid("id").defaultRandom().primaryKey(),
  endpointId: uuid("endpoint_id").references(() => webhookEndpoints.id).notNull(),
  event: text("event").notNull(),
  payload: jsonb("payload").notNull(),
  statusCode: integer("status_code"),
  response: text("response"),
  attempts: integer("attempts").default(0),
  maxAttempts: integer("max_attempts").default(5),
  nextRetryAt: timestamp("next_retry_at"),
  status: text("status", {
    enum: ["pending", "delivered", "failed", "retrying"],
  }).default("pending").notNull(),
  createdAt: timestamp("created_at").defaultNow(),
  deliveredAt: timestamp("delivered_at"),
});

Step 2: Webhook Dispatcher

// lib/webhooks.ts
import { db } from "@/db";
import { webhookEndpoints, webhookDeliveries } from "@/db/schema";
import { eq, and, arrayContains } from "drizzle-orm";
import { createHmac } from "crypto";

export async function dispatchWebhook(
  event: string,
  payload: Record<string, unknown>
) {
  // Find active endpoints subscribing to this event
  const endpoints = await db
    .select()
    .from(webhookEndpoints)
    .where(
      and(
        eq(webhookEndpoints.active, true),
        arrayContains(webhookEndpoints.events, [event])
      )
    );

  // Create delivery records and send
  const deliveries = await Promise.allSettled(
    endpoints.map(async (endpoint) => {
      const [delivery] = await db
        .insert(webhookDeliveries)
        .values({
          endpointId: endpoint.id,
          event,
          payload,
          status: "pending",
        })
        .returning();

      await sendWebhook(delivery.id, endpoint.url, endpoint.secret, event, payload);
      return delivery;
    })
  );

  return deliveries;
}

async function sendWebhook(
  deliveryId: string,
  url: string,
  secret: string,
  event: string,
  payload: Record<string, unknown>
) {
  const body = JSON.stringify(payload);
  const timestamp = Math.floor(Date.now() / 1000).toString();
  const signature = createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  try {
    const response = await fetch(url, {
      method: "POST",
      headers: {
        "Content-Type": "application/json",
        "X-Webhook-Event": event,
        "X-Webhook-Timestamp": timestamp,
        "X-Webhook-Signature": `sha256=${signature}`,
        "X-Webhook-Delivery": deliveryId,
      },
      body,
      signal: AbortSignal.timeout(10000), // 10s timeout
    });

    const responseText = await response.text().catch(() => "");

    if (response.ok) {
      await db
        .update(webhookDeliveries)
        .set({
          status: "delivered",
          statusCode: response.status,
          response: responseText.slice(0, 1000),
          attempts: 1,
          deliveredAt: new Date(),
        })
        .where(eq(webhookDeliveries.id, deliveryId));
    } else {
      await scheduleRetry(deliveryId, response.status, responseText);
    }
  } catch (error) {
    await scheduleRetry(
      deliveryId,
      0,
      error instanceof Error ? error.message : "Unknown error"
    );
  }
}

async function scheduleRetry(
  deliveryId: string,
  statusCode: number,
  response: string
) {
  const [delivery] = await db
    .select()
    .from(webhookDeliveries)
    .where(eq(webhookDeliveries.id, deliveryId));

  const attempts = (delivery.attempts ?? 0) + 1;
  const maxAttempts = delivery.maxAttempts ?? 5;

  if (attempts >= maxAttempts) {
    await db
      .update(webhookDeliveries)
      .set({
        status: "failed",
        statusCode,
        response: response.slice(0, 1000),
        attempts,
      })
      .where(eq(webhookDeliveries.id, deliveryId));
    return;
  }

  // Exponential backoff: 1m, 5m, 30m, 2h, 12h
  const delays = [60, 300, 1800, 7200, 43200];
  const delay = delays[attempts - 1] ?? 43200;

  await db
    .update(webhookDeliveries)
    .set({
      status: "retrying",
      statusCode,
      response: response.slice(0, 1000),
      attempts,
      nextRetryAt: new Date(Date.now() + delay * 1000),
    })
    .where(eq(webhookDeliveries.id, deliveryId));
}

Step 3: Endpoint Management API

// app/api/webhooks/endpoints/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { webhookEndpoints } from "@/db/schema";
import { randomBytes } from "crypto";
import { z } from "zod";

const createSchema = z.object({
  url: z.string().url(),
  description: z.string().optional(),
  events: z.array(z.string()).min(1),
});

export async function GET() {
  const endpoints = await db.select().from(webhookEndpoints);
  return NextResponse.json({ endpoints });
}

export async function POST(request: NextRequest) {
  const body = await request.json();
  const parsed = createSchema.safeParse(body);

  if (!parsed.success) {
    return NextResponse.json({ error: parsed.error.flatten() }, { status: 400 });
  }

  const secret = `whsec_${randomBytes(24).toString("hex")}`;

  const [endpoint] = await db
    .insert(webhookEndpoints)
    .values({
      url: parsed.data.url,
      description: parsed.data.description,
      events: parsed.data.events,
      secret,
    })
    .returning();

  // Return secret only on creation
  return NextResponse.json({ endpoint: { ...endpoint, secret } }, { status: 201 });
}

Step 4: Delivery Logs API

// app/api/webhooks/deliveries/route.ts
import { NextRequest, NextResponse } from "next/server";
import { db } from "@/db";
import { webhookDeliveries } from "@/db/schema";
import { desc, eq } from "drizzle-orm";

export async function GET(request: NextRequest) {
  const endpointId = new URL(request.url).searchParams.get("endpointId");

  const query = db
    .select()
    .from(webhookDeliveries)
    .orderBy(desc(webhookDeliveries.createdAt))
    .limit(50);

  if (endpointId) {
    const deliveries = await query.where(eq(webhookDeliveries.endpointId, endpointId));
    return NextResponse.json({ deliveries });
  }

  const deliveries = await query;
  return NextResponse.json({ deliveries });
}

Step 5: Dashboard UI

// app/dashboard/webhooks/page.tsx
"use client";

import { useState, useEffect } from "react";

interface Endpoint {
  id: string;
  url: string;
  description: string | null;
  events: string[];
  active: boolean;
  createdAt: string;
}

interface Delivery {
  id: string;
  endpointId: string;
  event: string;
  status: string;
  statusCode: number | null;
  attempts: number;
  createdAt: string;
  deliveredAt: string | null;
}

const EVENT_TYPES = [
  "order.created",
  "order.updated",
  "order.cancelled",
  "customer.created",
  "customer.updated",
  "payment.succeeded",
  "payment.failed",
];

export default function WebhookDashboard() {
  const [endpoints, setEndpoints] = useState<Endpoint[]>([]);
  const [deliveries, setDeliveries] = useState<Delivery[]>([]);
  const [selectedEndpoint, setSelectedEndpoint] = useState<string | null>(null);
  const [showCreate, setShowCreate] = useState(false);

  useEffect(() => {
    fetch("/api/webhooks/endpoints")
      .then((r) => r.json())
      .then((d) => setEndpoints(d.endpoints));
    fetch("/api/webhooks/deliveries")
      .then((r) => r.json())
      .then((d) => setDeliveries(d.deliveries));
  }, []);

  return (
    <div className="container py-10">
      <div className="mb-6 flex items-center justify-between">
        <h1 className="text-2xl font-bold">Webhooks</h1>
        <button
          onClick={() => setShowCreate(true)}
          className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700"
        >
          Add Endpoint
        </button>
      </div>

      {/* Endpoints */}
      <div className="mb-8 space-y-3">
        {endpoints.map((ep) => (
          <div
            key={ep.id}
            onClick={() => setSelectedEndpoint(ep.id)}
            className={`cursor-pointer rounded-lg border p-4 transition ${
              selectedEndpoint === ep.id ? "border-blue-600 ring-1 ring-blue-600" : ""
            }`}
          >
            <div className="flex items-center justify-between">
              <div>
                <code className="text-sm">{ep.url}</code>
                {ep.description && (
                  <p className="mt-1 text-xs text-gray-500">{ep.description}</p>
                )}
              </div>
              <span
                className={`rounded-full px-2 py-1 text-xs ${
                  ep.active ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"
                }`}
              >
                {ep.active ? "Active" : "Inactive"}
              </span>
            </div>
            <div className="mt-2 flex flex-wrap gap-1">
              {ep.events.map((event) => (
                <span key={event} className="rounded bg-gray-100 px-2 py-0.5 text-xs">
                  {event}
                </span>
              ))}
            </div>
          </div>
        ))}
      </div>

      {/* Delivery Logs */}
      <h2 className="mb-4 text-lg font-semibold">Recent Deliveries</h2>
      <div className="overflow-hidden rounded-lg border">
        <table className="w-full text-sm">
          <thead className="border-b bg-gray-50">
            <tr>
              <th className="px-4 py-3 text-left">Event</th>
              <th className="px-4 py-3 text-left">Status</th>
              <th className="px-4 py-3 text-left">Code</th>
              <th className="px-4 py-3 text-left">Attempts</th>
              <th className="px-4 py-3 text-left">Time</th>
            </tr>
          </thead>
          <tbody className="divide-y">
            {deliveries
              .filter((d) => !selectedEndpoint || d.endpointId === selectedEndpoint)
              .map((delivery) => (
                <tr key={delivery.id} className="hover:bg-gray-50">
                  <td className="px-4 py-3 font-mono text-xs">{delivery.event}</td>
                  <td className="px-4 py-3">
                    <StatusBadge status={delivery.status} />
                  </td>
                  <td className="px-4 py-3 font-mono text-xs">
                    {delivery.statusCode ?? "-"}
                  </td>
                  <td className="px-4 py-3 text-xs">{delivery.attempts}</td>
                  <td className="px-4 py-3 text-xs text-gray-500">
                    {new Date(delivery.createdAt).toLocaleString()}
                  </td>
                </tr>
              ))}
          </tbody>
        </table>
      </div>
    </div>
  );
}

function StatusBadge({ status }: { status: string }) {
  const colors: Record<string, string> = {
    delivered: "bg-green-100 text-green-800",
    pending: "bg-yellow-100 text-yellow-800",
    retrying: "bg-orange-100 text-orange-800",
    failed: "bg-red-100 text-red-800",
  };
  return (
    <span className={`rounded-full px-2 py-1 text-xs font-medium ${colors[status] ?? "bg-gray-100"}`}>
      {status}
    </span>
  );
}

Step 6: Signature Verification (Consumer Side)

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

export function verifyWebhookSignature(
  body: string,
  signature: string,
  timestamp: string,
  secret: string
): boolean {
  // Prevent replay attacks (5 minute tolerance)
  const ts = parseInt(timestamp, 10);
  if (Math.abs(Date.now() / 1000 - ts) > 300) {
    return false;
  }

  const expected = createHmac("sha256", secret)
    .update(`${timestamp}.${body}`)
    .digest("hex");

  const sig = signature.replace("sha256=", "");

  return timingSafeEqual(
    Buffer.from(sig, "hex"),
    Buffer.from(expected, "hex")
  );
}

Need API and Webhook Infrastructure?

We build robust API platforms with webhook systems, event-driven architectures, and real-time integrations. Contact us to discuss your requirements.

webhooksdashboardAPIevent-drivenNext.jstutorial

Ready to Start Your Project?

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

Get in Touch

Related Articles