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.