diff --git a/apps/web/package.json b/apps/web/package.json
index ddcae8b0..20f6f549 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -39,6 +39,7 @@
"@trpc/react-query": "^11.1.1",
"@trpc/server": "^11.1.1",
"@usesend/email-editor": "workspace:*",
+ "@usesend/lib": "workspace:*",
"@usesend/ui": "workspace:*",
"bullmq": "^5.51.1",
"chrono-node": "^2.8.0",
diff --git a/apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql b/apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql
new file mode 100644
index 00000000..449d859e
--- /dev/null
+++ b/apps/web/prisma/migrations/20251122195838_add_webhook/migration.sql
@@ -0,0 +1,66 @@
+-- CreateEnum
+CREATE TYPE "WebhookStatus" AS ENUM ('ACTIVE', 'PAUSED', 'AUTO_DISABLED');
+
+-- CreateEnum
+CREATE TYPE "WebhookCallStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'DELIVERED', 'FAILED', 'DISCARDED');
+
+-- CreateTable
+CREATE TABLE "Webhook" (
+ "id" TEXT NOT NULL,
+ "teamId" INTEGER NOT NULL,
+ "url" TEXT NOT NULL,
+ "description" TEXT,
+ "secret" TEXT NOT NULL,
+ "status" "WebhookStatus" NOT NULL DEFAULT 'ACTIVE',
+ "eventTypes" TEXT[],
+ "apiVersion" TEXT,
+ "consecutiveFailures" INTEGER NOT NULL DEFAULT 0,
+ "lastFailureAt" TIMESTAMP(3),
+ "lastSuccessAt" TIMESTAMP(3),
+ "createdByUserId" INTEGER,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "Webhook_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateTable
+CREATE TABLE "WebhookCall" (
+ "id" TEXT NOT NULL,
+ "webhookId" TEXT NOT NULL,
+ "teamId" INTEGER NOT NULL,
+ "type" TEXT NOT NULL,
+ "payload" TEXT NOT NULL,
+ "status" "WebhookCallStatus" NOT NULL DEFAULT 'PENDING',
+ "attempt" INTEGER NOT NULL DEFAULT 0,
+ "nextAttemptAt" TIMESTAMP(3),
+ "lastError" TEXT,
+ "responseStatus" INTEGER,
+ "responseTimeMs" INTEGER,
+ "responseText" TEXT,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "WebhookCall_pkey" PRIMARY KEY ("id")
+);
+
+-- CreateIndex
+CREATE INDEX "Webhook_teamId_idx" ON "Webhook"("teamId");
+
+-- CreateIndex
+CREATE INDEX "WebhookCall_teamId_webhookId_status_idx" ON "WebhookCall"("teamId", "webhookId", "status");
+
+-- CreateIndex
+CREATE INDEX "WebhookCall_createdAt_idx" ON "WebhookCall"("createdAt" DESC);
+
+-- AddForeignKey
+ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "Webhook" ADD CONSTRAINT "Webhook_createdByUserId_fkey" FOREIGN KEY ("createdByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WebhookCall" ADD CONSTRAINT "WebhookCall_webhookId_fkey" FOREIGN KEY ("webhookId") REFERENCES "Webhook"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+
+-- AddForeignKey
+ALTER TABLE "WebhookCall" ADD CONSTRAINT "WebhookCall_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "Team"("id") ON DELETE CASCADE ON UPDATE CASCADE;
diff --git a/apps/web/prisma/schema.prisma b/apps/web/prisma/schema.prisma
index 772d23b1..9ef292b6 100644
--- a/apps/web/prisma/schema.prisma
+++ b/apps/web/prisma/schema.prisma
@@ -79,17 +79,18 @@ model VerificationToken {
}
model User {
- id Int @id @default(autoincrement())
- name String?
- email String? @unique
- emailVerified DateTime?
- image String?
- isBetaUser Boolean @default(false)
- isWaitlisted Boolean @default(false)
- createdAt DateTime @default(now())
- accounts Account[]
- sessions Session[]
- teamUsers TeamUser[]
+ id Int @id @default(autoincrement())
+ name String?
+ email String? @unique
+ emailVerified DateTime?
+ image String?
+ isBetaUser Boolean @default(false)
+ isWaitlisted Boolean @default(false)
+ createdAt DateTime @default(now())
+ accounts Account[]
+ sessions Session[]
+ teamUsers TeamUser[]
+ webhookEndpoints Webhook[]
}
enum Plan {
@@ -122,6 +123,8 @@ model Team {
subscription Subscription[]
invites TeamInvite[]
suppressionList SuppressionList[]
+ webhookEndpoints Webhook[]
+ webhookCalls WebhookCall[]
}
model TeamInvite {
@@ -443,3 +446,61 @@ model SuppressionList {
@@unique([teamId, email])
}
+
+enum WebhookStatus {
+ ACTIVE
+ PAUSED
+ AUTO_DISABLED
+}
+
+enum WebhookCallStatus {
+ PENDING
+ IN_PROGRESS
+ DELIVERED
+ FAILED
+ DISCARDED
+}
+
+model Webhook {
+ id String @id @default(cuid())
+ teamId Int
+ url String
+ description String?
+ secret String
+ status WebhookStatus @default(ACTIVE)
+ eventTypes String[]
+ apiVersion String?
+ consecutiveFailures Int @default(0)
+ lastFailureAt DateTime?
+ lastSuccessAt DateTime?
+ createdByUserId Int?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
+ calls WebhookCall[]
+ createdBy User? @relation(fields: [createdByUserId], references: [id], onDelete: SetNull)
+
+ @@index([teamId])
+}
+
+model WebhookCall {
+ id String @id @default(cuid())
+ webhookId String
+ teamId Int
+ type String
+ payload String
+ status WebhookCallStatus @default(PENDING)
+ attempt Int @default(0)
+ nextAttemptAt DateTime?
+ lastError String?
+ responseStatus Int?
+ responseTimeMs Int?
+ responseText String?
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+ webhook Webhook @relation(fields: [webhookId], references: [id], onDelete: Cascade)
+ team Team @relation(fields: [teamId], references: [id], onDelete: Cascade)
+
+ @@index([teamId, webhookId, status])
+ @@index([createdAt(sort: Desc)])
+}
diff --git a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx
index ae2c3b57..ada8ecf7 100644
--- a/apps/web/src/app/(dashboard)/dev-settings/layout.tsx
+++ b/apps/web/src/app/(dashboard)/dev-settings/layout.tsx
@@ -1,5 +1,6 @@
"use client";
+import { H1 } from "@usesend/ui";
import { SettingsNavButton } from "./settings-nav-button";
export const dynamic = "force-static";
@@ -11,7 +12,7 @@ export default function ApiKeysPage({
}) {
return (
-
Developer settings
+
Developer Settings
API Keys
SMTP
diff --git a/apps/web/src/app/(dashboard)/emails/email-details.tsx b/apps/web/src/app/(dashboard)/emails/email-details.tsx
index 1fc45e8a..f5bbedbf 100644
--- a/apps/web/src/app/(dashboard)/emails/email-details.tsx
+++ b/apps/web/src/app/(dashboard)/emails/email-details.tsx
@@ -19,7 +19,7 @@ import {
BOUNCE_ERROR_MESSAGES,
COMPLAINT_ERROR_MESSAGES,
DELIVERY_DELAY_ERRORS,
-} from "~/lib/constants/ses-errors";
+} from "@usesend/lib/src/constants/ses-errors";
import CancelEmail from "./cancel-email";
import { useEffect } from "react";
import { useState } from "react";
@@ -75,7 +75,7 @@ export default function EmailDetails({ emailId }: { emailId: string }) {
{formatDate(
emailQuery.data?.scheduledAt,
- "MMM dd'th', hh:mm a"
+ "MMM dd'th', hh:mm a",
)}
diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx
new file mode 100644
index 00000000..4d588dd5
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/page.tsx
@@ -0,0 +1,292 @@
+"use client";
+
+import { use, useState, useEffect } from "react";
+import Link from "next/link";
+import { api } from "~/trpc/react";
+import {
+ Breadcrumb,
+ BreadcrumbItem,
+ BreadcrumbLink,
+ BreadcrumbList,
+ BreadcrumbPage,
+ BreadcrumbSeparator,
+} from "@usesend/ui/src/breadcrumb";
+import { Button } from "@usesend/ui/src/button";
+import {
+ Edit3,
+ Key,
+ MoreVertical,
+ Pause,
+ Play,
+ TestTube,
+ CircleEllipsis,
+} from "lucide-react";
+import { toast } from "@usesend/ui/src/toaster";
+import { WebhookInfo } from "./webhook-info";
+import { WebhookCallsTable } from "./webhook-calls-table";
+import { WebhookCallDetails } from "./webhook-call-details";
+import { DeleteWebhook } from "../delete-webhook";
+import { EditWebhookDialog } from "../webhook-update-dialog";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@usesend/ui/src/popover";
+import { type Webhook } from "@prisma/client";
+
+function WebhookDetailActions({
+ webhook,
+ onTest,
+ onEdit,
+ onToggleStatus,
+ onRotateSecret,
+ isTestPending,
+ isToggling,
+ isRotating,
+}: {
+ webhook: Webhook;
+ onTest: () => void;
+ onEdit: () => void;
+ onToggleStatus: () => void;
+ onRotateSecret: () => void;
+ isTestPending: boolean;
+ isToggling: boolean;
+ isRotating: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ const isPaused = webhook.status === "PAUSED";
+ const isAutoDisabled = webhook.status === "AUTO_DISABLED";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function WebhookDetailPage({
+ params,
+}: {
+ params: Promise<{ webhookId: string }>;
+}) {
+ const { webhookId } = use(params);
+ const [selectedCallId, setSelectedCallId] = useState
(null);
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
+
+ const webhookQuery = api.webhook.getById.useQuery({ id: webhookId });
+ const testWebhook = api.webhook.test.useMutation();
+ const setStatusMutation = api.webhook.setStatus.useMutation();
+ const updateWebhook = api.webhook.update.useMutation();
+ const callsQuery = api.webhook.listCalls.useQuery({
+ webhookId,
+ limit: 50,
+ });
+ const utils = api.useUtils();
+
+ const webhook = webhookQuery.data;
+
+ useEffect(() => {
+ if (!selectedCallId && callsQuery.data?.items.length) {
+ setSelectedCallId(callsQuery.data.items[0]!.id);
+ }
+ }, [callsQuery.data, selectedCallId]);
+
+ const handleTest = () => {
+ testWebhook.mutate(
+ { id: webhookId },
+ {
+ onSuccess: async () => {
+ await utils.webhook.listCalls.invalidate();
+ toast.success("Test webhook enqueued");
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ },
+ );
+ };
+
+ const handleToggleStatus = (currentStatus: string) => {
+ const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
+ setStatusMutation.mutate(
+ { id: webhookId, status: newStatus },
+ {
+ onSuccess: async () => {
+ await utils.webhook.getById.invalidate();
+ toast.success(
+ `Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`,
+ );
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ },
+ );
+ };
+
+ const handleRotateSecret = () => {
+ updateWebhook.mutate(
+ { id: webhookId, rotateSecret: true },
+ {
+ onSuccess: async () => {
+ await utils.webhook.getById.invalidate();
+ toast.success("Secret rotated successfully");
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ },
+ );
+ };
+
+ if (webhookQuery.isLoading) {
+ return (
+
+ );
+ }
+
+ if (!webhook) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+
+
+
+
+ Webhooks
+
+
+
+
+
+
+ {webhook.url}
+
+
+
+
+
+ setIsEditDialogOpen(true)}
+ onToggleStatus={() => handleToggleStatus(webhook.status)}
+ onRotateSecret={handleRotateSecret}
+ isTestPending={testWebhook.isPending}
+ isToggling={setStatusMutation.isPending}
+ isRotating={updateWebhook.isPending}
+ />
+
+
+
+
+
+
+
+
+
+
+ {selectedCallId ? (
+
+ ) : (
+
+ Select a webhook call to view details
+
+ )}
+
+
+
+ {isEditDialogOpen && (
+
+ )}
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx
new file mode 100644
index 00000000..6cd96454
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx
@@ -0,0 +1,174 @@
+"use client";
+
+import { formatDate } from "date-fns";
+import { RefreshCw } from "lucide-react";
+import { Button } from "@usesend/ui/src/button";
+import { api } from "~/trpc/react";
+import { toast } from "@usesend/ui/src/toaster";
+import { WebhookCallStatusBadge } from "../webhook-call-status-badge";
+
+import { CodeDisplay } from "~/components/code-display";
+
+const WEBHOOK_EVENT_VERSION = "2024-11-01";
+
+export function WebhookCallDetails({ callId }: { callId: string }) {
+ const callQuery = api.webhook.getCall.useQuery({ id: callId });
+ const retryMutation = api.webhook.retryCall.useMutation();
+ const utils = api.useUtils();
+
+ const call = callQuery.data;
+
+ if (!call) {
+ return (
+
+
+
Call Details
+
+
+
+ Loading call details...
+
+
+
+ );
+ }
+
+ const handleRetry = () => {
+ retryMutation.mutate(
+ { id: call.id },
+ {
+ onSuccess: async () => {
+ await utils.webhook.listCalls.invalidate();
+ await utils.webhook.getCall.invalidate();
+ toast.success("Webhook call queued for retry");
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ },
+ );
+ };
+
+ // Reconstruct the full payload that was actually sent to the webhook endpoint
+ const buildFullPayload = () => {
+ let data: unknown;
+ try {
+ data = JSON.parse(call.payload);
+ } catch {
+ data = call.payload;
+ }
+
+ return {
+ id: call.id,
+ type: call.type,
+ version: call.webhook?.apiVersion ?? WEBHOOK_EVENT_VERSION,
+ createdAt: new Date(call.createdAt).toISOString(),
+ teamId: call.teamId,
+ data,
+ attempt: call.attempt,
+ };
+ };
+
+ const fullPayload = buildFullPayload();
+
+ return (
+
+
+
Call Details
+ {call.status === "FAILED" && (
+
+ )}
+
+
+
+
+
+
+
+ Event Type
+
+ {call.type}
+
+
+
+
+ Timestamp
+
+
+ {formatDate(call.createdAt, "MMM dd, yyyy HH:mm:ss")}
+
+
+
+
+
+ Attempt
+
+ {call.attempt}
+
+
+ {call.responseStatus && (
+
+
+ Response Status
+
+ {call.responseStatus}
+
+ )}
+
+ {call.responseTimeMs != null && (
+
+
+ Duration
+
+ {call.responseTimeMs}ms
+
+ )}
+
+
+ {call.lastError && (
+
+
+ Error
+
+
+ {call.lastError}
+
+
+ )}
+
+
+
Request Payload
+
+
+
+ {call.responseText && (
+ <>
+
+
Response Body
+
+
+ >
+ )}
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx
new file mode 100644
index 00000000..6100f4b1
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-calls-table.tsx
@@ -0,0 +1,173 @@
+"use client";
+
+import { useState } from "react";
+import { WebhookCallStatus } from "@prisma/client";
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@usesend/ui/src/table";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@usesend/ui/src/select";
+import { Button } from "@usesend/ui/src/button";
+import Spinner from "@usesend/ui/src/spinner";
+import { api } from "~/trpc/react";
+import { formatDistanceToNow } from "date-fns";
+import { WebhookCallStatusBadge } from "../webhook-call-status-badge";
+
+const PAGE_SIZE = 20;
+
+export function WebhookCallsTable({
+ webhookId,
+ selectedCallId,
+ onSelectCall,
+}: {
+ webhookId: string;
+ selectedCallId: string | null;
+ onSelectCall: (callId: string) => void;
+}) {
+ const [statusFilter, setStatusFilter] = useState(
+ "ALL",
+ );
+ const [cursors, setCursors] = useState([]);
+
+ const currentCursor = cursors[cursors.length - 1];
+
+ const callsQuery = api.webhook.listCalls.useQuery({
+ webhookId,
+ status: statusFilter === "ALL" ? undefined : statusFilter,
+ limit: PAGE_SIZE,
+ cursor: currentCursor,
+ });
+
+ const calls = callsQuery.data?.items ?? [];
+ const nextCursor = callsQuery.data?.nextCursor;
+
+ const handleNextPage = () => {
+ if (nextCursor) {
+ setCursors([...cursors, nextCursor]);
+ }
+ };
+
+ const handlePrevPage = () => {
+ setCursors(cursors.slice(0, -1));
+ };
+
+ const handleFilterChange = (value: WebhookCallStatus | "ALL") => {
+ setStatusFilter(value);
+ setCursors([]);
+ };
+
+ return (
+
+
+
Delivery Logs
+
+
+
+
+
+
+ Status
+ Event Type
+ Time
+
+
+
+
+
+
+ {callsQuery.isLoading ? (
+
+
+
+
+
+ ) : calls.length === 0 ? (
+
+
+
+ No webhook calls yet
+
+
+
+ ) : (
+ calls.map((call) => (
+ onSelectCall(call.id)}
+ >
+
+
+
+
+
+
+ {call.type}
+
+
+ {formatDistanceToNow(call.createdAt, {
+ addSuffix: true,
+ })}
+
+
+ ))
+ )}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx
new file mode 100644
index 00000000..a5185d1f
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-info.tsx
@@ -0,0 +1,115 @@
+"use client";
+
+import { Webhook, WebhookCallStatus } from "@prisma/client";
+import { formatDistanceToNow } from "date-fns";
+import { Copy, Eye, EyeOff } from "lucide-react";
+import { useState } from "react";
+import { Button } from "@usesend/ui/src/button";
+import { toast } from "@usesend/ui/src/toaster";
+import { api } from "~/trpc/react";
+import { Badge } from "@usesend/ui/src/badge";
+import { WebhookStatusBadge } from "../webhook-status-badge";
+
+export function WebhookInfo({ webhook }: { webhook: Webhook }) {
+ const [showSecret, setShowSecret] = useState(false);
+
+ const sevenDaysAgo = new Date();
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
+
+ const callsQuery = api.webhook.listCalls.useQuery({
+ webhookId: webhook.id,
+ limit: 50,
+ });
+
+ const calls = callsQuery.data?.items ?? [];
+ const last7DaysCalls = calls.filter(
+ (call) => new Date(call.createdAt) >= sevenDaysAgo,
+ );
+
+ const deliveredCount = last7DaysCalls.filter(
+ (c) => c.status === WebhookCallStatus.DELIVERED,
+ ).length;
+ const failedCount = last7DaysCalls.filter(
+ (c) => c.status === WebhookCallStatus.FAILED,
+ ).length;
+ const pendingCount = last7DaysCalls.filter(
+ (c) =>
+ c.status === WebhookCallStatus.PENDING ||
+ c.status === WebhookCallStatus.IN_PROGRESS,
+ ).length;
+
+ const handleCopySecret = () => {
+ navigator.clipboard.writeText(webhook.secret);
+ toast.success("Secret copied to clipboard");
+ };
+
+ return (
+
+
+
Events
+
+ {webhook.eventTypes.length === 0 ? (
+ All events
+ ) : (
+ <>
+ {webhook.eventTypes.slice(0, 2).map((event) => (
+
+ {event}
+
+ ))}
+ {webhook.eventTypes.length > 2 && (
+
+ +{webhook.eventTypes.length - 2} more
+
+ )}
+ >
+ )}
+
+
+
+
+
+ Created
+
+ {formatDistanceToNow(webhook.createdAt, { addSuffix: true })}
+
+
+
+
+
+
Signing Secret
+
+
+
+
+
+
+ {showSecret ? webhook.secret : "whsec_••••••••••••••••••••••••"}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx
new file mode 100644
index 00000000..afc3b278
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-payload-display.tsx
@@ -0,0 +1,37 @@
+import { CodeBlock } from "@usesend/ui/src/code-block";
+
+interface WebhookPayloadDisplayProps {
+ payload: string;
+ title: string;
+ lang?: "json" | "text";
+}
+
+export async function WebhookPayloadDisplay({
+ payload,
+ title,
+ lang = "json",
+}: WebhookPayloadDisplayProps) {
+ let displayContent = payload;
+
+ // For JSON, try to pretty-print it
+ if (lang === "json") {
+ try {
+ const parsed = JSON.parse(payload);
+ displayContent = JSON.stringify(parsed, null, 2);
+ } catch {
+ // If parsing fails, use as-is
+ displayContent = payload;
+ }
+ }
+
+ return (
+
+
{title}
+
+
+ {displayContent}
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx b/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx
new file mode 100644
index 00000000..26dc26de
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/add-webhook.tsx
@@ -0,0 +1,333 @@
+"use client";
+
+import { useState } from "react";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { Button } from "@usesend/ui/src/button";
+import { Input } from "@usesend/ui/src/input";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@usesend/ui/src/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@usesend/ui/src/form";
+import { api } from "~/trpc/react";
+import { useForm } from "react-hook-form";
+import { toast } from "@usesend/ui/src/toaster";
+import { ChevronDown, Plus } from "lucide-react";
+import {
+ ContactEvents,
+ DomainEvents,
+ EmailEvents,
+ WebhookEvents,
+ type WebhookEventType,
+} from "@usesend/lib/src/webhook/webhook-events";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuTrigger,
+} from "@usesend/ui/src/dropdown-menu";
+import { LimitReason } from "~/lib/constants/plans";
+import { useUpgradeModalStore } from "~/store/upgradeModalStore";
+
+const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
+
+const webhookSchema = z.object({
+ url: z
+ .string({ required_error: "URL is required" })
+ .url("Please enter a valid URL"),
+ eventTypes: z.array(EVENT_TYPES_ENUM, {
+ required_error: "Select at least one event",
+ }),
+});
+
+type WebhookFormValues = z.infer;
+
+const eventGroups: {
+ label: string;
+ events: readonly WebhookEventType[];
+}[] = [
+ { label: "Contact events", events: ContactEvents },
+ { label: "Domain events", events: DomainEvents },
+ { label: "Email events", events: EmailEvents },
+];
+
+export function AddWebhook() {
+ const [open, setOpen] = useState(false);
+ const [allEventsSelected, setAllEventsSelected] = useState(false);
+ const createWebhookMutation = api.webhook.create.useMutation();
+ const limitsQuery = api.limits.get.useQuery({ type: LimitReason.WEBHOOK });
+ const { openModal } = useUpgradeModalStore((s) => s.action);
+
+ const utils = api.useUtils();
+
+ const form = useForm({
+ resolver: zodResolver(webhookSchema),
+ defaultValues: {
+ url: "",
+ eventTypes: [],
+ },
+ });
+
+ function onOpenChange(nextOpen: boolean) {
+ if (nextOpen && limitsQuery.data?.isLimitReached) {
+ openModal(limitsQuery.data.reason);
+ return;
+ }
+
+ setOpen(nextOpen);
+ }
+
+ function handleSubmit(values: WebhookFormValues) {
+ if (limitsQuery.data?.isLimitReached) {
+ openModal(limitsQuery.data.reason);
+ return;
+ }
+
+ const selectedEvents = values.eventTypes ?? [];
+
+ if (!allEventsSelected && selectedEvents.length === 0) {
+ toast.error("Select at least one event or all events");
+ return;
+ }
+
+ createWebhookMutation.mutate(
+ {
+ url: values.url,
+ eventTypes: allEventsSelected ? [] : selectedEvents,
+ },
+ {
+ onSuccess: async () => {
+ await utils.webhook.list.invalidate();
+ form.reset({
+ url: "",
+ eventTypes: [],
+ });
+ setAllEventsSelected(false);
+ setOpen(false);
+ toast.success("Webhook created successfully");
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ },
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx b/apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx
new file mode 100644
index 00000000..56a5ba17
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/delete-webhook.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { Button } from "@usesend/ui/src/button";
+import { DeleteResource } from "~/components/DeleteResource";
+import { api } from "~/trpc/react";
+import { type Webhook } from "@prisma/client";
+import { toast } from "@usesend/ui/src/toaster";
+import { z } from "zod";
+import { Trash2 } from "lucide-react";
+
+export const DeleteWebhook: React.FC<{
+ webhook: Webhook;
+}> = ({ webhook }) => {
+ const deleteWebhookMutation = api.webhook.delete.useMutation();
+ const utils = api.useUtils();
+
+ const schema = z
+ .object({
+ confirmation: z.string().min(1, "Please type the webhook URL to confirm"),
+ })
+ .refine((data) => data.confirmation === webhook.url, {
+ message: "Webhook URL does not match",
+ path: ["confirmation"],
+ });
+
+ async function onConfirm(values: z.infer) {
+ deleteWebhookMutation.mutate(
+ { id: webhook.id },
+ {
+ onSuccess: async () => {
+ await utils.webhook.list.invalidate();
+ toast.success("Webhook deleted");
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ },
+ );
+ }
+
+ return (
+
+
+ Delete
+
+ }
+ />
+ );
+};
diff --git a/apps/web/src/app/(dashboard)/webhooks/page.tsx b/apps/web/src/app/(dashboard)/webhooks/page.tsx
new file mode 100644
index 00000000..4182b61a
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/page.tsx
@@ -0,0 +1,17 @@
+"use client";
+
+import { H1 } from "@usesend/ui";
+import { AddWebhook } from "./add-webhook";
+import { WebhookList } from "./webhook-list";
+
+export default function WebhooksPage() {
+ return (
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx b/apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx
new file mode 100644
index 00000000..a7b85d94
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/webhook-call-status-badge.tsx
@@ -0,0 +1,41 @@
+import { WebhookCallStatus } from "@prisma/client";
+
+export function WebhookCallStatusBadge({
+ status,
+}: {
+ status: WebhookCallStatus;
+}) {
+ let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
+ let label: string = status;
+
+ switch (status) {
+ case WebhookCallStatus.DELIVERED:
+ badgeColor = "bg-green/15 text-green border border-green/20";
+ label = "Delivered";
+ break;
+ case WebhookCallStatus.FAILED:
+ badgeColor = "bg-red/15 text-red border border-red/20";
+ label = "Failed";
+ break;
+ case WebhookCallStatus.PENDING:
+ badgeColor = "bg-yellow/20 text-yellow border border-yellow/10";
+ label = "Pending";
+ break;
+ case WebhookCallStatus.IN_PROGRESS:
+ badgeColor = "bg-blue/15 text-blue border border-blue/20";
+ label = "In Progress";
+ break;
+ case WebhookCallStatus.DISCARDED:
+ badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
+ label = "Discarded";
+ break;
+ }
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx b/apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx
new file mode 100644
index 00000000..29452606
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/webhook-list.tsx
@@ -0,0 +1,212 @@
+"use client";
+
+import {
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@usesend/ui/src/table";
+import Spinner from "@usesend/ui/src/spinner";
+import { api } from "~/trpc/react";
+import { formatDistanceToNow } from "date-fns";
+import { Edit3, MoreVertical, Pause, Play, Trash2 } from "lucide-react";
+import { Button } from "@usesend/ui/src/button";
+import { toast } from "@usesend/ui/src/toaster";
+import { DeleteWebhook } from "./delete-webhook";
+import { useState } from "react";
+import { EditWebhookDialog } from "./webhook-update-dialog";
+import { useRouter } from "next/navigation";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@usesend/ui/src/popover";
+import { type Webhook } from "@prisma/client";
+import { WebhookStatusBadge } from "./webhook-status-badge";
+
+export function WebhookList() {
+ const webhooksQuery = api.webhook.list.useQuery();
+ const testWebhook = api.webhook.test.useMutation();
+ const setStatusMutation = api.webhook.setStatus.useMutation();
+ const utils = api.useUtils();
+ const router = useRouter();
+ const [editingId, setEditingId] = useState(null);
+
+ const webhooks = webhooksQuery.data ?? [];
+
+ async function handleToggleStatus(webhookId: string, currentStatus: string) {
+ const newStatus = currentStatus === "ACTIVE" ? "PAUSED" : "ACTIVE";
+ setStatusMutation.mutate(
+ { id: webhookId, status: newStatus },
+ {
+ onSuccess: async () => {
+ await utils.webhook.list.invalidate();
+ toast.success(
+ `Webhook ${newStatus === "ACTIVE" ? "resumed" : "paused"}`,
+ );
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ },
+ );
+ }
+
+ return (
+
+
+
+
+
+ URL
+ Status
+ Last success
+ Last failure
+
+ Actions
+
+
+
+
+ {webhooksQuery.isLoading ? (
+
+
+
+
+
+ ) : webhooks.length === 0 ? (
+
+
+ No webhooks configured
+
+
+ ) : (
+ webhooks.map((webhook) => (
+ router.push(`/webhooks/${webhook.id}`)}
+ >
+
+ {webhook.url}
+
+
+
+
+
+ {webhook.lastSuccessAt
+ ? formatDistanceToNow(webhook.lastSuccessAt, {
+ addSuffix: true,
+ })
+ : "Never"}
+
+
+ {webhook.lastFailureAt
+ ? formatDistanceToNow(webhook.lastFailureAt, {
+ addSuffix: true,
+ })
+ : "Never"}
+
+
+ e.stopPropagation()}
+ >
+ setEditingId(webhook.id)}
+ onToggleStatus={() =>
+ handleToggleStatus(webhook.id, webhook.status)
+ }
+ isToggling={setStatusMutation.isPending}
+ />
+
+ {editingId === webhook.id ? (
+
+ setEditingId(open ? webhook.id : null)
+ }
+ />
+ ) : null}
+
+
+ ))
+ )}
+
+
+
+
+ );
+}
+
+function WebhookActions({
+ webhook,
+ onEdit,
+ onToggleStatus,
+ isToggling,
+}: {
+ webhook: Webhook;
+ onEdit: () => void;
+ onToggleStatus: () => void;
+ isToggling: boolean;
+}) {
+ const [open, setOpen] = useState(false);
+ const isPaused = webhook.status === "PAUSED";
+ const isAutoDisabled = webhook.status === "AUTO_DISABLED";
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx b/apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx
new file mode 100644
index 00000000..6e48b9ad
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/webhook-status-badge.tsx
@@ -0,0 +1,25 @@
+import { WebhookStatus } from "@prisma/client";
+
+export function WebhookStatusBadge({ status }: { status: WebhookStatus }) {
+ let badgeColor = "bg-gray-700/10 text-gray-400 border border-gray-400/10";
+ let label: string = status;
+
+ if (status === WebhookStatus.ACTIVE) {
+ badgeColor = "bg-green/15 text-green border border-green/20";
+ label = "Active";
+ } else if (status === WebhookStatus.PAUSED) {
+ badgeColor = "bg-yellow/15 text-yellow border border-yellow/20";
+ label = "Paused";
+ } else if (status === WebhookStatus.AUTO_DISABLED) {
+ badgeColor = "bg-red/15 text-red border border-red/20";
+ label = "Auto disabled";
+ }
+
+ return (
+
+ {label}
+
+ );
+}
diff --git a/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx b/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx
new file mode 100644
index 00000000..25954322
--- /dev/null
+++ b/apps/web/src/app/(dashboard)/webhooks/webhook-update-dialog.tsx
@@ -0,0 +1,323 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { z } from "zod";
+import { zodResolver } from "@hookform/resolvers/zod";
+import { useForm } from "react-hook-form";
+import {
+ Dialog,
+ DialogContent,
+ DialogHeader,
+ DialogTitle,
+} from "@usesend/ui/src/dialog";
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@usesend/ui/src/form";
+import { Input } from "@usesend/ui/src/input";
+import { Button } from "@usesend/ui/src/button";
+import { ChevronDown } from "lucide-react";
+import { api } from "~/trpc/react";
+import {
+ ContactEvents,
+ DomainEvents,
+ EmailEvents,
+ WebhookEvents,
+ type WebhookEventType,
+} from "@usesend/lib/src/webhook/webhook-events";
+import {
+ DropdownMenu,
+ DropdownMenuCheckboxItem,
+ DropdownMenuContent,
+ DropdownMenuLabel,
+ DropdownMenuSeparator,
+ DropdownMenuTrigger,
+} from "@usesend/ui/src/dropdown-menu";
+import { toast } from "@usesend/ui/src/toaster";
+import type { Webhook } from "@prisma/client";
+
+const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
+
+const editWebhookSchema = z.object({
+ url: z
+ .string({ required_error: "URL is required" })
+ .url("Please enter a valid URL"),
+ eventTypes: z.array(EVENT_TYPES_ENUM, {
+ required_error: "Select at least one event",
+ }),
+});
+
+type EditWebhookFormValues = z.infer;
+
+const eventGroups: {
+ label: string;
+ events: readonly WebhookEventType[];
+}[] = [
+ { label: "Email events", events: EmailEvents },
+ { label: "Domain events", events: DomainEvents },
+ { label: "Contact events", events: ContactEvents },
+];
+
+export function EditWebhookDialog({
+ webhook,
+ open,
+ onOpenChange,
+}: {
+ webhook: Webhook;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+}) {
+ const updateWebhook = api.webhook.update.useMutation();
+ const utils = api.useUtils();
+ const initialHasAllEvents =
+ (webhook.eventTypes as WebhookEventType[]).length === 0;
+ const [allEventsSelected, setAllEventsSelected] =
+ useState(initialHasAllEvents);
+
+ const form = useForm({
+ resolver: zodResolver(editWebhookSchema),
+ defaultValues: {
+ url: webhook.url,
+ eventTypes: initialHasAllEvents
+ ? []
+ : (webhook.eventTypes as WebhookEventType[]),
+ },
+ });
+
+ useEffect(() => {
+ if (open) {
+ const hasAllEvents =
+ (webhook.eventTypes as WebhookEventType[]).length === 0;
+ form.reset({
+ url: webhook.url,
+ eventTypes: hasAllEvents
+ ? []
+ : (webhook.eventTypes as WebhookEventType[]),
+ });
+ setAllEventsSelected(hasAllEvents);
+ }
+ }, [open, webhook, form]);
+
+ function handleSubmit(values: EditWebhookFormValues) {
+ const selectedEvents = values.eventTypes ?? [];
+
+ if (!allEventsSelected && selectedEvents.length === 0) {
+ toast.error("Select at least one event or all events");
+ return;
+ }
+
+ updateWebhook.mutate(
+ {
+ id: webhook.id,
+ url: values.url,
+ eventTypes: allEventsSelected ? [] : selectedEvents,
+ },
+ {
+ onSuccess: async () => {
+ await utils.webhook.list.invalidate();
+ toast.success("Webhook updated");
+ onOpenChange(false);
+ },
+ onError: (error) => {
+ toast.error(error.message);
+ },
+ },
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/components/AppSideBar.tsx b/apps/web/src/components/AppSideBar.tsx
index 47bf9f8e..751d6693 100644
--- a/apps/web/src/components/AppSideBar.tsx
+++ b/apps/web/src/components/AppSideBar.tsx
@@ -17,6 +17,7 @@ import {
UsersIcon,
GaugeIcon,
UserRoundX,
+ Webhook,
} from "lucide-react";
import { signOut } from "next-auth/react";
@@ -98,6 +99,11 @@ const settingsItems = [
url: "/domains",
icon: Globe,
},
+ {
+ title: "Webhooks",
+ url: "/webhooks",
+ icon: Webhook,
+ },
{
title: "Developer settings",
url: "/dev-settings",
diff --git a/apps/web/src/components/code-display.tsx b/apps/web/src/components/code-display.tsx
new file mode 100644
index 00000000..d9b1c509
--- /dev/null
+++ b/apps/web/src/components/code-display.tsx
@@ -0,0 +1,111 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { BundledLanguage, codeToHtml } from "shiki";
+import { Check, Copy } from "lucide-react";
+import { Button } from "@usesend/ui/src/button";
+
+interface CodeDisplayProps {
+ code: string;
+ language?: BundledLanguage;
+ className?: string;
+ maxHeight?: string;
+}
+
+export function CodeDisplay({
+ code,
+ language = "json",
+ className = "",
+ maxHeight = "300px",
+}: CodeDisplayProps) {
+ const [html, setHtml] = useState("");
+ const [isLoading, setIsLoading] = useState(true);
+ const [copied, setCopied] = useState(false);
+
+ useEffect(() => {
+ let isMounted = true;
+
+ async function highlight() {
+ try {
+ const highlighted = await codeToHtml(code, {
+ lang: language,
+ themes: {
+ dark: "catppuccin-mocha",
+ light: "catppuccin-latte",
+ },
+ decorations: [],
+ cssVariablePrefix: "--shiki-",
+ });
+
+ if (isMounted) {
+ setHtml(highlighted);
+ setIsLoading(false);
+ }
+ } catch (error) {
+ console.error("Failed to highlight code:", error);
+ if (isMounted) {
+ setIsLoading(false);
+ }
+ }
+ }
+
+ highlight();
+
+ return () => {
+ isMounted = false;
+ };
+ }, [code, language]);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(code);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ console.error("Failed to copy to clipboard:", error);
+ }
+ };
+
+ if (isLoading) {
+ return (
+
+
+
+ {code}
+
+
+ );
+ }
+
+ return (
+
+ );
+}
diff --git a/apps/web/src/lib/constants/plans.ts b/apps/web/src/lib/constants/plans.ts
index bd1e7099..8f7c27a1 100644
--- a/apps/web/src/lib/constants/plans.ts
+++ b/apps/web/src/lib/constants/plans.ts
@@ -4,6 +4,7 @@ export enum LimitReason {
DOMAIN = "DOMAIN",
CONTACT_BOOK = "CONTACT_BOOK",
TEAM_MEMBER = "TEAM_MEMBER",
+ WEBHOOK = "WEBHOOK",
EMAIL_BLOCKED = "EMAIL_BLOCKED",
EMAIL_DAILY_LIMIT_REACHED = "EMAIL_DAILY_LIMIT_REACHED",
EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED = "EMAIL_FREE_PLAN_MONTHLY_LIMIT_REACHED",
@@ -17,6 +18,7 @@ export const PLAN_LIMITS: Record<
domains: number;
contactBooks: number;
teamMembers: number;
+ webhooks: number;
}
> = {
FREE: {
@@ -25,6 +27,7 @@ export const PLAN_LIMITS: Record<
domains: 1,
contactBooks: 1,
teamMembers: 1,
+ webhooks: 1,
},
BASIC: {
emailsPerMonth: -1, // unlimited
@@ -32,5 +35,6 @@ export const PLAN_LIMITS: Record<
domains: -1,
contactBooks: -1,
teamMembers: -1,
+ webhooks: -1,
},
};
diff --git a/apps/web/src/server/api/root.ts b/apps/web/src/server/api/root.ts
index b9f7f033..d581bec6 100644
--- a/apps/web/src/server/api/root.ts
+++ b/apps/web/src/server/api/root.ts
@@ -14,6 +14,7 @@ import { suppressionRouter } from "./routers/suppression";
import { limitsRouter } from "./routers/limits";
import { waitlistRouter } from "./routers/waitlist";
import { feedbackRouter } from "./routers/feedback";
+import { webhookRouter } from "./routers/webhook";
/**
* This is the primary router for your server.
@@ -36,6 +37,7 @@ export const appRouter = createTRPCRouter({
limits: limitsRouter,
waitlist: waitlistRouter,
feedback: feedbackRouter,
+ webhook: webhookRouter,
});
// export type definition of API
diff --git a/apps/web/src/server/api/routers/contacts.ts b/apps/web/src/server/api/routers/contacts.ts
index 416b2913..e6e565a1 100644
--- a/apps/web/src/server/api/routers/contacts.ts
+++ b/apps/web/src/server/api/routers/contacts.ts
@@ -151,15 +151,15 @@ export const contactsRouter = createTRPCRouter({
subscribed: z.boolean().optional(),
}),
)
- .mutation(async ({ input }) => {
+ .mutation(async ({ ctx: { team }, input }) => {
const { contactId, ...contact } = input;
- return contactService.updateContact(contactId, contact);
+ return contactService.updateContact(contactId, contact, team.id);
}),
deleteContact: contactBookProcedure
.input(z.object({ contactId: z.string() }))
- .mutation(async ({ input }) => {
- return contactService.deleteContact(input.contactId);
+ .mutation(async ({ ctx: { team }, input }) => {
+ return contactService.deleteContact(input.contactId, team.id);
}),
exportContacts: contactBookProcedure
diff --git a/apps/web/src/server/api/routers/email.ts b/apps/web/src/server/api/routers/email.ts
index 709fc8ef..787d2daa 100644
--- a/apps/web/src/server/api/routers/email.ts
+++ b/apps/web/src/server/api/routers/email.ts
@@ -2,7 +2,7 @@ import { Email, EmailStatus, Prisma } from "@prisma/client";
import { format, subDays } from "date-fns";
import { z } from "zod";
import { DEFAULT_QUERY_LIMIT } from "~/lib/constants";
-import { BOUNCE_ERROR_MESSAGES } from "~/lib/constants/ses-errors";
+import { BOUNCE_ERROR_MESSAGES } from "@usesend/lib/src";
import type { SesBounce } from "~/types/aws-types";
import {
@@ -95,12 +95,12 @@ export const emailRouter = createTRPCRouter({
const offset = (page - 1) * limit;
const emails = await db.$queryRaw>`
- SELECT
- id,
- "createdAt",
- "latestStatus",
- subject,
- "to",
+ SELECT
+ id,
+ "createdAt",
+ "latestStatus",
+ subject,
+ "to",
"scheduledAt"
FROM "Email"
WHERE "teamId" = ${ctx.team.id}
@@ -110,9 +110,9 @@ export const emailRouter = createTRPCRouter({
${
input.search
? Prisma.sql`AND (
- "subject" ILIKE ${`%${input.search}%`}
+ "subject" ILIKE ${`%${input.search}%`}
OR EXISTS (
- SELECT 1 FROM unnest("to") AS email
+ SELECT 1 FROM unnest("to") AS email
WHERE email ILIKE ${`%${input.search}%`}
)
)`
@@ -201,7 +201,12 @@ export const emailRouter = createTRPCRouter({
} as const;
if (email.latestStatus !== "BOUNCED" || !email.bounceData) {
- return { ...base, bounceType: undefined, bounceSubType: undefined, bounceReason: undefined };
+ return {
+ ...base,
+ bounceType: undefined,
+ bounceSubType: undefined,
+ bounceReason: undefined,
+ };
}
const bounce = ensureBounceObject(email.bounceData);
@@ -209,7 +214,9 @@ export const emailRouter = createTRPCRouter({
const bounceSubType = bounce?.bounceSubType
? bounce.bounceSubType.toString().trim().replace(/\s+/g, "")
: undefined;
- const bounceReason = bounce ? getBounceReasonFromParsed(bounce) : undefined;
+ const bounceReason = bounce
+ ? getBounceReasonFromParsed(bounce)
+ : undefined;
return { ...base, bounceType, bounceSubType, bounceReason };
});
diff --git a/apps/web/src/server/api/routers/limits.ts b/apps/web/src/server/api/routers/limits.ts
index 94369ee3..8b62e9d1 100644
--- a/apps/web/src/server/api/routers/limits.ts
+++ b/apps/web/src/server/api/routers/limits.ts
@@ -8,7 +8,7 @@ export const limitsRouter = createTRPCRouter({
.input(
z.object({
type: z.nativeEnum(LimitReason),
- })
+ }),
)
.query(async ({ ctx, input }) => {
switch (input.type) {
@@ -18,6 +18,8 @@ export const limitsRouter = createTRPCRouter({
return LimitService.checkDomainLimit(ctx.team.id);
case LimitReason.TEAM_MEMBER:
return LimitService.checkTeamMemberLimit(ctx.team.id);
+ case LimitReason.WEBHOOK:
+ return LimitService.checkWebhookLimit(ctx.team.id);
default:
// exhaustive guard
throw new Error("Unsupported limit type");
diff --git a/apps/web/src/server/api/routers/webhook.ts b/apps/web/src/server/api/routers/webhook.ts
new file mode 100644
index 00000000..2d44ce96
--- /dev/null
+++ b/apps/web/src/server/api/routers/webhook.ts
@@ -0,0 +1,135 @@
+import { z } from "zod";
+import { createTRPCRouter, teamProcedure } from "~/server/api/trpc";
+import { WebhookCallStatus, WebhookStatus } from "@prisma/client";
+import { WebhookEvents } from "@usesend/lib/src/webhook/webhook-events";
+import { WebhookService } from "~/server/service/webhook-service";
+
+const EVENT_TYPES_ENUM = z.enum(WebhookEvents);
+
+export const webhookRouter = createTRPCRouter({
+ list: teamProcedure.query(async ({ ctx }) => {
+ return WebhookService.listWebhooks(ctx.team.id);
+ }),
+
+ getById: teamProcedure
+ .input(z.object({ id: z.string() }))
+ .query(async ({ ctx, input }) => {
+ return WebhookService.getWebhook({
+ id: input.id,
+ teamId: ctx.team.id,
+ });
+ }),
+
+ create: teamProcedure
+ .input(
+ z.object({
+ url: z.string().url(),
+ description: z.string().optional(),
+ eventTypes: z.array(EVENT_TYPES_ENUM),
+ secret: z.string().min(16).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ return WebhookService.createWebhook({
+ teamId: ctx.team.id,
+ userId: ctx.session.user.id,
+ url: input.url,
+ description: input.description,
+ eventTypes: input.eventTypes,
+ secret: input.secret,
+ });
+ }),
+
+ update: teamProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ url: z.string().url().optional(),
+ description: z.string().nullable().optional(),
+ eventTypes: z.array(EVENT_TYPES_ENUM).optional(),
+ rotateSecret: z.boolean().optional(),
+ secret: z.string().min(16).optional(),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ return WebhookService.updateWebhook({
+ id: input.id,
+ teamId: ctx.team.id,
+ url: input.url,
+ description: input.description,
+ eventTypes: input.eventTypes,
+ rotateSecret: input.rotateSecret,
+ secret: input.secret,
+ });
+ }),
+
+ setStatus: teamProcedure
+ .input(
+ z.object({
+ id: z.string(),
+ status: z.nativeEnum(WebhookStatus),
+ }),
+ )
+ .mutation(async ({ ctx, input }) => {
+ return WebhookService.setWebhookStatus({
+ id: input.id,
+ teamId: ctx.team.id,
+ status: input.status,
+ });
+ }),
+
+ delete: teamProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ return WebhookService.deleteWebhook({
+ id: input.id,
+ teamId: ctx.team.id,
+ });
+ }),
+
+ test: teamProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ return WebhookService.testWebhook({
+ webhookId: input.id,
+ teamId: ctx.team.id,
+ });
+ }),
+
+ listCalls: teamProcedure
+ .input(
+ z.object({
+ webhookId: z.string().optional(),
+ status: z.nativeEnum(WebhookCallStatus).optional(),
+ limit: z.number().min(1).max(50).default(20),
+ cursor: z.string().optional(),
+ }),
+ )
+ .query(async ({ ctx, input }) => {
+ return WebhookService.listWebhookCalls({
+ teamId: ctx.team.id,
+ webhookId: input.webhookId,
+ status: input.status,
+ limit: input.limit,
+ cursor: input.cursor,
+ });
+ }),
+
+ getCall: teamProcedure
+ .input(z.object({ id: z.string() }))
+ .query(async ({ ctx, input }) => {
+ return WebhookService.getWebhookCall({
+ id: input.id,
+ teamId: ctx.team.id,
+ });
+ }),
+
+ retryCall: teamProcedure
+ .input(z.object({ id: z.string() }))
+ .mutation(async ({ ctx, input }) => {
+ return WebhookService.retryCall({
+ callId: input.id,
+ teamId: ctx.team.id,
+ });
+ }),
+});
diff --git a/apps/web/src/server/jobs/webhook-cleanup-job.ts b/apps/web/src/server/jobs/webhook-cleanup-job.ts
new file mode 100644
index 00000000..35ae446c
--- /dev/null
+++ b/apps/web/src/server/jobs/webhook-cleanup-job.ts
@@ -0,0 +1,55 @@
+import { Queue, Worker } from "bullmq";
+import { subDays } from "date-fns";
+import { db } from "~/server/db";
+import { getRedis } from "~/server/redis";
+import { DEFAULT_QUEUE_OPTIONS, WEBHOOK_CLEANUP_QUEUE } from "../queue/queue-constants";
+import { logger } from "../logger/log";
+
+const WEBHOOK_RETENTION_DAYS = 30;
+
+const webhookCleanupQueue = new Queue(WEBHOOK_CLEANUP_QUEUE, {
+ connection: getRedis(),
+});
+
+const worker = new Worker(
+ WEBHOOK_CLEANUP_QUEUE,
+ async () => {
+ const cutoff = subDays(new Date(), WEBHOOK_RETENTION_DAYS);
+ const result = await db.webhookCall.deleteMany({
+ where: {
+ createdAt: {
+ lt: cutoff,
+ },
+ },
+ });
+
+ logger.info(
+ { deleted: result.count, cutoff: cutoff.toISOString() },
+ "[WebhookCleanupJob]: Deleted old webhook calls",
+ );
+ },
+ {
+ connection: getRedis(),
+ }
+);
+
+await webhookCleanupQueue.upsertJobScheduler(
+ "webhook-cleanup-daily",
+ {
+ pattern: "0 3 * * *", // daily at 03:00 UTC
+ tz: "UTC",
+ },
+ {
+ opts: {
+ ...DEFAULT_QUEUE_OPTIONS,
+ },
+ }
+);
+
+worker.on("completed", (job) => {
+ logger.info({ jobId: job.id }, "[WebhookCleanupJob]: Job completed");
+});
+
+worker.on("failed", (job, err) => {
+ logger.error({ err, jobId: job?.id }, "[WebhookCleanupJob]: Job failed");
+});
diff --git a/apps/web/src/server/public-api/api/contacts/add-contact.ts b/apps/web/src/server/public-api/api/contacts/add-contact.ts
index 17e39d97..85a3bd53 100644
--- a/apps/web/src/server/public-api/api/contacts/add-contact.ts
+++ b/apps/web/src/server/public-api/api/contacts/add-contact.ts
@@ -1,6 +1,5 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
-import { getTeamFromToken } from "~/server/public-api/auth";
import { addOrUpdateContact } from "~/server/service/contact-service";
import { getContactBook } from "../../api-utils";
@@ -55,7 +54,8 @@ function addContact(app: PublicAPIApp) {
const contact = await addOrUpdateContact(
contactBook.id,
- c.req.valid("json")
+ c.req.valid("json"),
+ team.id,
);
return c.json({ contactId: contact.id });
diff --git a/apps/web/src/server/public-api/api/contacts/delete-contact.ts b/apps/web/src/server/public-api/api/contacts/delete-contact.ts
index 4e54f43f..e77a08f6 100644
--- a/apps/web/src/server/public-api/api/contacts/delete-contact.ts
+++ b/apps/web/src/server/public-api/api/contacts/delete-contact.ts
@@ -1,6 +1,5 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
-import { getTeamFromToken } from "~/server/public-api/auth";
import { deleteContact } from "~/server/service/contact-service";
import { getContactBook } from "../../api-utils";
@@ -44,7 +43,7 @@ function deleteContactHandler(app: PublicAPIApp) {
await getContactBook(c, team.id);
const contactId = c.req.param("contactId");
- await deleteContact(contactId);
+ await deleteContact(contactId, team.id);
return c.json({ success: true });
});
diff --git a/apps/web/src/server/public-api/api/contacts/update-contact.ts b/apps/web/src/server/public-api/api/contacts/update-contact.ts
index d1846bf7..dfba238b 100644
--- a/apps/web/src/server/public-api/api/contacts/update-contact.ts
+++ b/apps/web/src/server/public-api/api/contacts/update-contact.ts
@@ -1,6 +1,5 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
-import { getTeamFromToken } from "~/server/public-api/auth";
import { updateContact } from "~/server/service/contact-service";
import { getContactBook } from "../../api-utils";
@@ -57,7 +56,11 @@ function updateContactInfo(app: PublicAPIApp) {
await getContactBook(c, team.id);
const contactId = c.req.param("contactId");
- const contact = await updateContact(contactId, c.req.valid("json"));
+ const contact = await updateContact(
+ contactId,
+ c.req.valid("json"),
+ team.id,
+ );
return c.json({ contactId: contact.id });
});
diff --git a/apps/web/src/server/public-api/api/contacts/upsert-contact.ts b/apps/web/src/server/public-api/api/contacts/upsert-contact.ts
index 958902c3..7b389800 100644
--- a/apps/web/src/server/public-api/api/contacts/upsert-contact.ts
+++ b/apps/web/src/server/public-api/api/contacts/upsert-contact.ts
@@ -1,6 +1,5 @@
import { createRoute, z } from "@hono/zod-openapi";
import { PublicAPIApp } from "~/server/public-api/hono";
-import { getTeamFromToken } from "~/server/public-api/auth";
import { addOrUpdateContact } from "~/server/service/contact-service";
import { getContactBook } from "../../api-utils";
@@ -55,7 +54,8 @@ function upsertContact(app: PublicAPIApp) {
const contact = await addOrUpdateContact(
contactBook.id,
- c.req.valid("json")
+ c.req.valid("json"),
+ team.id,
);
return c.json({ contactId: contact.id });
diff --git a/apps/web/src/server/queue/queue-constants.ts b/apps/web/src/server/queue/queue-constants.ts
index fb6d4c92..42944945 100644
--- a/apps/web/src/server/queue/queue-constants.ts
+++ b/apps/web/src/server/queue/queue-constants.ts
@@ -3,6 +3,8 @@ export const CAMPAIGN_MAIL_PROCESSING_QUEUE = "campaign-emails-processing";
export const CONTACT_BULK_ADD_QUEUE = "contact-bulk-add";
export const CAMPAIGN_BATCH_QUEUE = "campaign-batch";
export const CAMPAIGN_SCHEDULER_QUEUE = "campaign-scheduler";
+export const WEBHOOK_DISPATCH_QUEUE = "webhook-dispatch";
+export const WEBHOOK_CLEANUP_QUEUE = "webhook-cleanup";
export const DEFAULT_QUEUE_OPTIONS = {
removeOnComplete: true,
diff --git a/apps/web/src/server/service/contact-queue-service.ts b/apps/web/src/server/service/contact-queue-service.ts
index 3ed6ef06..a9093f8b 100644
--- a/apps/web/src/server/service/contact-queue-service.ts
+++ b/apps/web/src/server/service/contact-queue-service.ts
@@ -97,7 +97,7 @@ class ContactQueueService {
}
async function processContactJob(job: ContactJob) {
- const { contactBookId, contact } = job.data;
+ const { contactBookId, contact, teamId } = job.data;
logger.info(
{ contactEmail: contact.email, contactBookId },
@@ -105,7 +105,7 @@ async function processContactJob(job: ContactJob) {
);
try {
- await addOrUpdateContact(contactBookId, contact);
+ await addOrUpdateContact(contactBookId, contact, teamId);
logger.info(
{ contactEmail: contact.email },
"[ContactQueueService]: Successfully processed contact job",
diff --git a/apps/web/src/server/service/contact-service.ts b/apps/web/src/server/service/contact-service.ts
index 6d49eece..81d737f5 100644
--- a/apps/web/src/server/service/contact-service.ts
+++ b/apps/web/src/server/service/contact-service.ts
@@ -1,5 +1,12 @@
+import { type Contact } from "@prisma/client";
+import {
+ type ContactPayload,
+ type ContactWebhookEventType,
+} from "@usesend/lib/src/webhook/webhook-events";
import { db } from "../db";
import { ContactQueueService } from "./contact-queue-service";
+import { WebhookService } from "./webhook-service";
+import { logger } from "../logger/log";
export type ContactInput = {
email: string;
@@ -12,6 +19,7 @@ export type ContactInput = {
export async function addOrUpdateContact(
contactBookId: string,
contact: ContactInput,
+ teamId?: number,
) {
// Check if contact exists to handle subscribed logic
const existingContact = await db.contact.findUnique({
@@ -37,7 +45,7 @@ export async function addOrUpdateContact(
// All other cases (Yes→No, Yes→Yes, No→No) are allowed naturally
}
- const createdContact = await db.contact.upsert({
+ const savedContact = await db.contact.upsert({
where: {
contactBookId_email: {
contactBookId,
@@ -60,27 +68,42 @@ export async function addOrUpdateContact(
},
});
- return createdContact;
+ const eventType: ContactWebhookEventType = existingContact
+ ? "contact.updated"
+ : "contact.created";
+
+ await emitContactEvent(savedContact, eventType, teamId);
+
+ return savedContact;
}
export async function updateContact(
contactId: string,
contact: Partial,
+ teamId?: number,
) {
- return db.contact.update({
+ const updatedContact = await db.contact.update({
where: {
id: contactId,
},
data: contact,
});
+
+ await emitContactEvent(updatedContact, "contact.updated", teamId);
+
+ return updatedContact;
}
-export async function deleteContact(contactId: string) {
- return db.contact.delete({
+export async function deleteContact(contactId: string, teamId?: number) {
+ const deletedContact = await db.contact.delete({
where: {
id: contactId,
},
});
+
+ await emitContactEvent(deletedContact, "contact.deleted", teamId);
+
+ return deletedContact;
}
export async function bulkAddContacts(
@@ -117,3 +140,53 @@ export async function subscribeContact(contactId: string) {
},
});
}
+
+function buildContactPayload(contact: Contact): ContactPayload {
+ return {
+ id: contact.id,
+ email: contact.email,
+ contactBookId: contact.contactBookId,
+ subscribed: contact.subscribed,
+ properties: (contact.properties ?? {}) as Record,
+ firstName: contact.firstName,
+ lastName: contact.lastName,
+ createdAt: contact.createdAt.toISOString(),
+ updatedAt: contact.updatedAt.toISOString(),
+ };
+}
+
+async function emitContactEvent(
+ contact: Contact,
+ type: ContactWebhookEventType,
+ teamId?: number,
+) {
+ try {
+ const resolvedTeamId =
+ teamId ??
+ (await db.contactBook
+ .findUnique({
+ where: { id: contact.contactBookId },
+ select: { teamId: true },
+ })
+ .then((contactBook) => contactBook?.teamId));
+
+ if (!resolvedTeamId) {
+ logger.warn(
+ { contactId: contact.id },
+ "[ContactService]: Skipping webhook emission, teamId not found",
+ );
+ return;
+ }
+
+ await WebhookService.emit(
+ resolvedTeamId,
+ type,
+ buildContactPayload(contact),
+ );
+ } catch (error) {
+ logger.error(
+ { error, contactId: contact.id, type },
+ "[ContactService]: Failed to emit contact webhook event",
+ );
+ }
+}
diff --git a/apps/web/src/server/service/domain-service.ts b/apps/web/src/server/service/domain-service.ts
index 77632363..7b65fdb5 100644
--- a/apps/web/src/server/service/domain-service.ts
+++ b/apps/web/src/server/service/domain-service.ts
@@ -7,8 +7,13 @@ import { SesSettingsService } from "./ses-settings-service";
import { UnsendApiError } from "../public-api/api-error";
import { logger } from "../logger/log";
import { ApiKey, DomainStatus, type Domain } from "@prisma/client";
+import {
+ type DomainPayload,
+ type DomainWebhookEventType,
+} from "@usesend/lib/src/webhook/webhook-events";
import { LimitService } from "./limit-service";
import type { DomainDnsRecord } from "~/types/domain";
+import { WebhookService } from "./webhook-service";
const DOMAIN_STATUS_VALUES = new Set(Object.values(DomainStatus));
@@ -72,7 +77,7 @@ function buildDnsRecords(domain: Domain): DomainDnsRecord[] {
}
function withDnsRecords(
- domain: T
+ domain: T,
): T & { dnsRecords: DomainDnsRecord[] } {
return {
...domain,
@@ -82,6 +87,24 @@ function withDnsRecords(
const dnsResolveTxt = util.promisify(dns.resolveTxt);
+function buildDomainPayload(domain: Domain): DomainPayload {
+ return {
+ id: domain.id,
+ name: domain.name,
+ status: domain.status,
+ region: domain.region,
+ createdAt: domain.createdAt.toISOString(),
+ updatedAt: domain.updatedAt.toISOString(),
+ clickTracking: domain.clickTracking,
+ openTracking: domain.openTracking,
+ subdomain: domain.subdomain,
+ sesTenantId: domain.sesTenantId,
+ dkimStatus: domain.dkimStatus,
+ spfDetails: domain.spfDetails,
+ dmarcAdded: domain.dmarcAdded,
+ };
+}
+
export async function validateDomainFromEmail(email: string, teamId: number) {
// Extract email from format like 'Name ' this will allow entries such as "Someone @ something " to parse correctly as well.
const match = email.match(/<([^>]+)>/);
@@ -130,7 +153,7 @@ export async function validateDomainFromEmail(email: string, teamId: number) {
export async function validateApiKeyDomainAccess(
email: string,
teamId: number,
- apiKey: ApiKey & { domain?: { name: string } | null }
+ apiKey: ApiKey & { domain?: { name: string } | null },
) {
// First validate the domain exists and is verified
const domain = await validateDomainFromEmail(email, teamId);
@@ -155,7 +178,7 @@ export async function createDomain(
teamId: number,
name: string,
region: string,
- sesTenantId?: string
+ sesTenantId?: string,
) {
const domainStr = tldts.getDomain(name);
@@ -187,7 +210,7 @@ export async function createDomain(
name,
region,
sesTenantId,
- dkimSelector
+ dkimSelector,
);
const domain = await db.domain.create({
@@ -204,6 +227,8 @@ export async function createDomain(
},
});
+ await emitDomainEvent(domain, "domain.created");
+
return withDnsRecords(domain);
}
@@ -223,9 +248,10 @@ export async function getDomain(id: number, teamId: number) {
}
if (domain.isVerifying) {
+ const previousStatus = domain.status;
const domainIdentity = await ses.getDomainIdentity(
domain.name,
- domain.region
+ domain.region,
);
const dkimStatus = domainIdentity.DkimAttributes?.Status;
@@ -268,7 +294,7 @@ export async function getDomain(id: number, teamId: number) {
? lastCheckedTime.toISOString()
: (lastCheckedTime ?? null);
- return {
+ const response = {
...domainWithDns,
dkimStatus: normalizedDomain.dkimStatus,
spfDetails: normalizedDomain.spfDetails,
@@ -276,6 +302,16 @@ export async function getDomain(id: number, teamId: number) {
lastCheckedTime: normalizedLastCheckedTime,
dmarcAdded: normalizedDomain.dmarcAdded,
};
+
+ if (previousStatus !== domainWithDns.status) {
+ const eventType: DomainWebhookEventType =
+ domainWithDns.status === DomainStatus.SUCCESS
+ ? "domain.verified"
+ : "domain.updated";
+ await emitDomainEvent(domainWithDns, eventType);
+ }
+
+ return response;
}
return withDnsRecords(domain);
@@ -283,12 +319,16 @@ export async function getDomain(id: number, teamId: number) {
export async function updateDomain(
id: number,
- data: { clickTracking?: boolean; openTracking?: boolean }
+ data: { clickTracking?: boolean; openTracking?: boolean },
) {
- return db.domain.update({
+ const updated = await db.domain.update({
where: { id },
data,
});
+
+ await emitDomainEvent(updated, "domain.updated");
+
+ return updated;
}
export async function deleteDomain(id: number) {
@@ -303,7 +343,7 @@ export async function deleteDomain(id: number) {
const deleted = await ses.deleteDomain(
domain.name,
domain.region,
- domain.sesTenantId ?? undefined
+ domain.sesTenantId ?? undefined,
);
if (!deleted) {
@@ -312,12 +352,14 @@ export async function deleteDomain(id: number) {
const deletedRecord = await db.domain.delete({ where: { id } });
+ await emitDomainEvent(domain, "domain.deleted");
+
return deletedRecord;
}
export async function getDomains(
teamId: number,
- options?: { domainId?: number }
+ options?: { domainId?: number },
) {
const domains = await db.domain.findMany({
where: {
@@ -341,3 +383,14 @@ async function getDmarcRecord(domain: string) {
return null; // or handle error as appropriate
}
}
+
+async function emitDomainEvent(domain: Domain, type: DomainWebhookEventType) {
+ try {
+ await WebhookService.emit(domain.teamId, type, buildDomainPayload(domain));
+ } catch (error) {
+ logger.error(
+ { error, domainId: domain.id, type },
+ "[DomainService]: Failed to emit domain webhook event",
+ );
+ }
+}
diff --git a/apps/web/src/server/service/limit-service.ts b/apps/web/src/server/service/limit-service.ts
index c9c7462c..3f1a9fba 100644
--- a/apps/web/src/server/service/limit-service.ts
+++ b/apps/web/src/server/service/limit-service.ts
@@ -101,6 +101,36 @@ export class LimitService {
};
}
+ static async checkWebhookLimit(teamId: number): Promise<{
+ isLimitReached: boolean;
+ limit: number;
+ reason?: LimitReason;
+ }> {
+ // Limits only apply in cloud mode
+ if (!env.NEXT_PUBLIC_IS_CLOUD) {
+ return { isLimitReached: false, limit: -1 };
+ }
+
+ const team = await TeamService.getTeamCached(teamId);
+ const currentCount = await db.webhook.count({
+ where: { teamId },
+ });
+
+ const limit = PLAN_LIMITS[getActivePlan(team)].webhooks;
+ if (isLimitExceeded(currentCount, limit)) {
+ return {
+ isLimitReached: true,
+ limit,
+ reason: LimitReason.WEBHOOK,
+ };
+ }
+
+ return {
+ isLimitReached: false,
+ limit,
+ };
+ }
+
// Checks email sending limits and also triggers usage notifications.
// Side effects:
// - Sends "warning" emails when nearing daily/monthly limits (rate-limited in TeamService)
diff --git a/apps/web/src/server/service/ses-hook-parser.ts b/apps/web/src/server/service/ses-hook-parser.ts
index ae4c845c..88eb40ec 100644
--- a/apps/web/src/server/service/ses-hook-parser.ts
+++ b/apps/web/src/server/service/ses-hook-parser.ts
@@ -1,9 +1,14 @@
import {
EmailStatus,
- Prisma,
- UnsubscribeReason,
SuppressionReason,
+ UnsubscribeReason,
+ type Email,
} from "@prisma/client";
+import {
+ type EmailBasePayload,
+ type EmailEventPayloadMap,
+ type EmailWebhookEventType,
+} from "@usesend/lib/src/webhook/webhook-events";
import {
SesBounce,
SesClick,
@@ -25,6 +30,7 @@ import {
import { getChildLogger, logger, withLogger } from "../logger/log";
import { randomUUID } from "crypto";
import { SuppressionService } from "./suppression-service";
+import { WebhookService } from "./webhook-service";
export async function parseSesHook(data: SesEvent) {
const mailStatus = getEmailStatus(data);
@@ -270,9 +276,218 @@ export async function parseSesHook(data: SesEvent) {
logger.info("Email event created");
+ try {
+ const occurredAt = data.mail.timestamp
+ ? new Date(data.mail.timestamp).toISOString()
+ : new Date().toISOString();
+
+ const metadata = buildEmailMetadata(mailStatus, mailData);
+
+ await WebhookService.emit(
+ email.teamId,
+ emailStatusToEvent(mailStatus),
+ buildEmailWebhookPayload({
+ email,
+ status: mailStatus,
+ occurredAt,
+ eventData: mailData,
+ metadata,
+ }),
+ );
+ } catch (error) {
+ logger.error(
+ { error, emailId: email.id, mailStatus },
+ "[SesHookParser]: Failed to emit webhook",
+ );
+ }
+
return true;
}
+type EmailBounceSubType =
+ EmailEventPayloadMap["email.bounced"]["bounce"]["subType"];
+
+function buildEmailWebhookPayload(params: {
+ email: Email;
+ status: EmailStatus;
+ occurredAt: string;
+ eventData: SesEvent | SesEvent[SesEventDataKey];
+ metadata?: Record;
+}): EmailEventPayloadMap[EmailWebhookEventType] {
+ const { email, status, eventData, occurredAt, metadata } = params;
+
+ const basePayload: EmailBasePayload = {
+ id: email.id,
+ status,
+ from: email.from,
+ to: email.to,
+ occurredAt,
+ campaignId: email.campaignId ?? undefined,
+ contactId: email.contactId ?? undefined,
+ domainId: email.domainId ?? null,
+ subject: email.subject,
+ metadata,
+ };
+
+ switch (status) {
+ case EmailStatus.BOUNCED: {
+ const bounce = eventData as SesBounce | undefined;
+ return {
+ ...basePayload,
+ bounce: {
+ type: bounce?.bounceType ?? "Undetermined",
+ subType: normalizeBounceSubType(bounce?.bounceSubType),
+ message: bounce?.bouncedRecipients?.[0]?.diagnosticCode,
+ },
+ };
+ }
+ case EmailStatus.OPENED: {
+ const openData = eventData as SesEvent["open"];
+ return {
+ ...basePayload,
+ open: {
+ timestamp: openData?.timestamp ?? occurredAt,
+ userAgent: openData?.userAgent,
+ ip: openData?.ipAddress,
+ },
+ };
+ }
+ case EmailStatus.CLICKED: {
+ const clickData = eventData as SesClick | undefined;
+ return {
+ ...basePayload,
+ click: {
+ timestamp: clickData?.timestamp ?? occurredAt,
+ url: clickData?.link ?? "",
+ userAgent: clickData?.userAgent,
+ ip: clickData?.ipAddress,
+ },
+ };
+ }
+ default:
+ return basePayload;
+ }
+}
+
+function normalizeBounceSubType(
+ subType: SesBounce["bounceSubType"] | undefined,
+): EmailBounceSubType {
+ const normalized = subType?.replace(/\s+/g, "") as
+ | EmailBounceSubType
+ | undefined;
+
+ const validSubTypes: EmailBounceSubType[] = [
+ "General",
+ "NoEmail",
+ "Suppressed",
+ "OnAccountSuppressionList",
+ "MailboxFull",
+ "MessageTooLarge",
+ "ContentRejected",
+ "AttachmentRejected",
+ ];
+
+ if (normalized && validSubTypes.includes(normalized)) {
+ return normalized;
+ }
+
+ return "General";
+}
+
+function emailStatusToEvent(status: EmailStatus): EmailWebhookEventType {
+ switch (status) {
+ case EmailStatus.QUEUED:
+ return "email.queued";
+ case EmailStatus.SENT:
+ return "email.sent";
+ case EmailStatus.DELIVERY_DELAYED:
+ return "email.delivery_delayed";
+ case EmailStatus.DELIVERED:
+ return "email.delivered";
+ case EmailStatus.BOUNCED:
+ return "email.bounced";
+ case EmailStatus.REJECTED:
+ return "email.rejected";
+ case EmailStatus.RENDERING_FAILURE:
+ return "email.rendering_failure";
+ case EmailStatus.COMPLAINED:
+ return "email.complained";
+ case EmailStatus.FAILED:
+ return "email.failed";
+ case EmailStatus.CANCELLED:
+ return "email.cancelled";
+ case EmailStatus.SUPPRESSED:
+ return "email.suppressed";
+ case EmailStatus.OPENED:
+ return "email.opened";
+ case EmailStatus.CLICKED:
+ return "email.clicked";
+ default:
+ return "email.queued";
+ }
+}
+
+function buildEmailMetadata(
+ status: EmailStatus,
+ mailData: SesEvent | SesEvent[SesEventDataKey],
+) {
+ switch (status) {
+ case EmailStatus.BOUNCED: {
+ const bounce = mailData as SesBounce;
+ return {
+ bounceType: bounce.bounceType,
+ bounceSubType: bounce.bounceSubType,
+ diagnosticCode: bounce.bouncedRecipients?.[0]?.diagnosticCode,
+ };
+ }
+ case EmailStatus.COMPLAINED: {
+ const complaintInfo = (mailData as any)?.complaint ?? mailData;
+ return {
+ feedbackType: complaintInfo?.complaintFeedbackType,
+ userAgent: complaintInfo?.userAgent,
+ };
+ }
+ case EmailStatus.OPENED: {
+ const openData = (mailData as any)?.open ?? mailData;
+ return {
+ ipAddress: openData?.ipAddress,
+ userAgent: openData?.userAgent,
+ };
+ }
+ case EmailStatus.CLICKED: {
+ const click = mailData as SesClick;
+ return {
+ ipAddress: click.ipAddress,
+ userAgent: click.userAgent,
+ link: click.link,
+ };
+ }
+ case EmailStatus.RENDERING_FAILURE: {
+ const failure = mailData as SesEvent["renderingFailure"];
+ return {
+ errorMessage: failure?.errorMessage,
+ templateName: failure?.templateName,
+ };
+ }
+ case EmailStatus.DELIVERY_DELAYED: {
+ const deliveryDelay = mailData as SesEvent["deliveryDelay"];
+ return {
+ delayType: deliveryDelay?.delayType,
+ expirationTime: deliveryDelay?.expirationTime,
+ delayedRecipients: deliveryDelay?.delayedRecipients,
+ };
+ }
+ case EmailStatus.REJECTED: {
+ const reject = mailData as SesEvent["reject"];
+ return {
+ reason: reject?.reason,
+ };
+ }
+ default:
+ return undefined;
+ }
+}
+
async function checkUnsubscribe({
contactId,
campaignId,
diff --git a/apps/web/src/server/service/webhook-service.ts b/apps/web/src/server/service/webhook-service.ts
new file mode 100644
index 00000000..25bb0745
--- /dev/null
+++ b/apps/web/src/server/service/webhook-service.ts
@@ -0,0 +1,821 @@
+import { WebhookCallStatus, WebhookStatus } from "@prisma/client";
+import { Queue, Worker } from "bullmq";
+import { createHmac, randomUUID, randomBytes } from "crypto";
+import {
+ WebhookEventData,
+ WebhookPayloadData,
+ type WebhookEvent,
+ type WebhookEventPayloadMap,
+ type WebhookEventType,
+} from "@usesend/lib/src/webhook/webhook-events";
+import { db } from "../db";
+import { getRedis } from "../redis";
+import {
+ DEFAULT_QUEUE_OPTIONS,
+ WEBHOOK_DISPATCH_QUEUE,
+} from "../queue/queue-constants";
+import { createWorkerHandler, TeamJob } from "../queue/bullmq-context";
+import { logger } from "../logger/log";
+import { LimitService } from "./limit-service";
+import { UnsendApiError } from "../public-api/api-error";
+
+const WEBHOOK_DISPATCH_CONCURRENCY = 25;
+const WEBHOOK_MAX_ATTEMPTS = 6;
+const WEBHOOK_BASE_BACKOFF_MS = 5_000;
+const WEBHOOK_LOCK_TTL_MS = 15_000;
+const WEBHOOK_LOCK_RETRY_DELAY_MS = 2_000;
+const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30;
+const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000;
+const WEBHOOK_RESPONSE_TEXT_LIMIT = 4_096;
+const WEBHOOK_EVENT_VERSION = "2024-11-01";
+
+type WebhookCallJobData = {
+ callId: string;
+ teamId?: number;
+};
+
+type WebhookCallJob = TeamJob;
+
+type WebhookEventInput =
+ WebhookPayloadData;
+
+export class WebhookQueueService {
+ private static queue = new Queue(WEBHOOK_DISPATCH_QUEUE, {
+ connection: getRedis(),
+ defaultJobOptions: {
+ ...DEFAULT_QUEUE_OPTIONS,
+ attempts: WEBHOOK_MAX_ATTEMPTS,
+ backoff: {
+ type: "exponential",
+ delay: WEBHOOK_BASE_BACKOFF_MS,
+ },
+ },
+ });
+
+ private static worker = new Worker(
+ WEBHOOK_DISPATCH_QUEUE,
+ createWorkerHandler(processWebhookCall),
+ {
+ connection: getRedis(),
+ concurrency: WEBHOOK_DISPATCH_CONCURRENCY,
+ },
+ );
+
+ static {
+ this.worker.on("error", (error) => {
+ logger.error({ error }, "[WebhookQueueService]: Worker error");
+ });
+
+ logger.info("[WebhookQueueService]: Initialized webhook queue service");
+ }
+
+ public static async enqueueCall(callId: string, teamId: number) {
+ await this.queue.add(
+ callId,
+ {
+ callId,
+ teamId,
+ },
+ { jobId: callId },
+ );
+ }
+}
+
+export class WebhookService {
+ public static async emit(
+ teamId: number,
+ type: TType,
+ payload: WebhookEventInput,
+ ) {
+ const activeWebhooks = await db.webhook.findMany({
+ where: {
+ teamId,
+ status: WebhookStatus.ACTIVE,
+ OR: [
+ {
+ eventTypes: {
+ has: type,
+ },
+ },
+ {
+ eventTypes: {
+ isEmpty: true,
+ },
+ },
+ ],
+ },
+ });
+
+ if (activeWebhooks.length === 0) {
+ logger.debug(
+ { teamId, type },
+ "[WebhookService]: No active webhooks for event type",
+ );
+ return;
+ }
+
+ const payloadString = stringifyPayload(payload);
+
+ for (const webhook of activeWebhooks) {
+ const call = await db.webhookCall.create({
+ data: {
+ webhookId: webhook.id,
+ teamId: webhook.teamId,
+ type: type,
+ payload: payloadString,
+ status: WebhookCallStatus.PENDING,
+ attempt: 0,
+ },
+ });
+
+ await WebhookQueueService.enqueueCall(call.id, webhook.teamId);
+ }
+ }
+
+ public static async retryCall(params: { callId: string; teamId: number }) {
+ const call = await db.webhookCall.findFirst({
+ where: { id: params.callId, teamId: params.teamId },
+ });
+
+ if (!call) {
+ throw new Error("Webhook call not found");
+ }
+
+ await db.webhookCall.update({
+ where: { id: call.id },
+ data: {
+ status: WebhookCallStatus.PENDING,
+ attempt: 0,
+ nextAttemptAt: null,
+ lastError: null,
+ responseStatus: null,
+ responseTimeMs: null,
+ responseText: null,
+ },
+ });
+
+ await WebhookQueueService.enqueueCall(call.id, params.teamId);
+
+ return call.id;
+ }
+
+ public static async testWebhook(params: {
+ webhookId: string;
+ teamId: number;
+ }) {
+ const webhook = await db.webhook.findFirst({
+ where: { id: params.webhookId, teamId: params.teamId },
+ });
+
+ if (!webhook) {
+ throw new Error("Webhook not found");
+ }
+
+ const payload = {
+ test: true,
+ webhookId: webhook.id,
+ sentAt: new Date().toISOString(),
+ };
+
+ const call = await db.webhookCall.create({
+ data: {
+ webhookId: webhook.id,
+ teamId: webhook.teamId,
+ type: "webhook.test",
+ payload: stringifyPayload(payload),
+ status: WebhookCallStatus.PENDING,
+ attempt: 0,
+ },
+ });
+
+ await WebhookQueueService.enqueueCall(call.id, webhook.teamId);
+
+ return call.id;
+ }
+
+ public static generateSecret() {
+ return `whsec_${randomBytes(32).toString("hex")}`;
+ }
+
+ public static async listWebhooks(teamId: number) {
+ return db.webhook.findMany({
+ where: { teamId },
+ orderBy: { createdAt: "desc" },
+ });
+ }
+
+ public static async getWebhook(params: { id: string; teamId: number }) {
+ const webhook = await db.webhook.findFirst({
+ where: { id: params.id, teamId: params.teamId },
+ });
+
+ if (!webhook) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Webhook not found",
+ });
+ }
+
+ return webhook;
+ }
+
+ public static async createWebhook(params: {
+ teamId: number;
+ userId: number;
+ url: string;
+ description?: string;
+ eventTypes: string[];
+ secret?: string;
+ }) {
+ const { isLimitReached, reason } = await LimitService.checkWebhookLimit(
+ params.teamId,
+ );
+
+ if (isLimitReached) {
+ throw new UnsendApiError({
+ code: "FORBIDDEN",
+ message: reason ?? "Webhook limit reached",
+ });
+ }
+
+ const secret = params.secret ?? WebhookService.generateSecret();
+
+ return db.webhook.create({
+ data: {
+ teamId: params.teamId,
+ url: params.url,
+ description: params.description,
+ secret,
+ eventTypes: params.eventTypes,
+ status: WebhookStatus.ACTIVE,
+ createdByUserId: params.userId,
+ },
+ });
+ }
+
+ public static async updateWebhook(params: {
+ id: string;
+ teamId: number;
+ url?: string;
+ description?: string | null;
+ eventTypes?: string[];
+ rotateSecret?: boolean;
+ secret?: string;
+ }) {
+ const webhook = await db.webhook.findFirst({
+ where: { id: params.id, teamId: params.teamId },
+ });
+
+ if (!webhook) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Webhook not found",
+ });
+ }
+
+ const secret =
+ params.rotateSecret === true
+ ? WebhookService.generateSecret()
+ : params.secret;
+
+ return db.webhook.update({
+ where: { id: webhook.id },
+ data: {
+ url: params.url ?? webhook.url,
+ description:
+ params.description === undefined
+ ? webhook.description
+ : (params.description ?? null),
+ eventTypes: params.eventTypes ?? webhook.eventTypes,
+ secret: secret ?? webhook.secret,
+ },
+ });
+ }
+
+ public static async setWebhookStatus(params: {
+ id: string;
+ teamId: number;
+ status: WebhookStatus;
+ }) {
+ const webhook = await db.webhook.findFirst({
+ where: { id: params.id, teamId: params.teamId },
+ });
+
+ if (!webhook) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Webhook not found",
+ });
+ }
+
+ return db.webhook.update({
+ where: { id: webhook.id },
+ data: {
+ status: params.status,
+ consecutiveFailures:
+ params.status === WebhookStatus.ACTIVE
+ ? 0
+ : webhook.consecutiveFailures,
+ },
+ });
+ }
+
+ public static async deleteWebhook(params: { id: string; teamId: number }) {
+ const webhook = await db.webhook.findFirst({
+ where: { id: params.id, teamId: params.teamId },
+ });
+
+ if (!webhook) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Webhook not found",
+ });
+ }
+
+ return db.webhook.delete({
+ where: { id: webhook.id },
+ });
+ }
+
+ public static async listWebhookCalls(params: {
+ teamId: number;
+ webhookId?: string;
+ status?: WebhookCallStatus;
+ limit: number;
+ cursor?: string;
+ }) {
+ const calls = await db.webhookCall.findMany({
+ where: {
+ teamId: params.teamId,
+ webhookId: params.webhookId,
+ status: params.status,
+ },
+ orderBy: { createdAt: "desc" },
+ take: params.limit + 1,
+ cursor: params.cursor ? { id: params.cursor } : undefined,
+ });
+
+ let nextCursor: string | null = null;
+ if (calls.length > params.limit) {
+ const next = calls.pop();
+ nextCursor = next?.id ?? null;
+ }
+
+ return {
+ items: calls,
+ nextCursor,
+ };
+ }
+
+ public static async getWebhookCall(params: { id: string; teamId: number }) {
+ const call = await db.webhookCall.findFirst({
+ where: { id: params.id, teamId: params.teamId },
+ include: {
+ webhook: {
+ select: {
+ apiVersion: true,
+ },
+ },
+ },
+ });
+
+ if (!call) {
+ throw new UnsendApiError({
+ code: "NOT_FOUND",
+ message: "Webhook call not found",
+ });
+ }
+
+ return call;
+ }
+}
+
+function stringifyPayload(payload: unknown) {
+ if (typeof payload === "string") {
+ return payload;
+ }
+
+ try {
+ return JSON.stringify(payload);
+ } catch (error) {
+ logger.error(
+ { error },
+ "[WebhookService]: Failed to stringify payload, falling back to empty object",
+ );
+ return "{}";
+ }
+}
+
+async function processWebhookCall(job: WebhookCallJob) {
+ const attempt = job.attemptsMade + 1;
+ const call = await db.webhookCall.findUnique({
+ where: { id: job.data.callId },
+ include: {
+ webhook: true,
+ },
+ });
+
+ if (!call) {
+ logger.warn(
+ { callId: job.data.callId },
+ "[WebhookQueueService]: Call not found",
+ );
+ return;
+ }
+
+ if (call.webhook.status !== WebhookStatus.ACTIVE) {
+ await db.webhookCall.update({
+ where: { id: call.id },
+ data: {
+ status: WebhookCallStatus.DISCARDED,
+ attempt,
+ },
+ });
+ logger.info(
+ { callId: call.id, webhookId: call.webhookId },
+ "[WebhookQueueService]: Discarded call because webhook is not active",
+ );
+ return;
+ }
+
+ await db.webhookCall.update({
+ where: { id: call.id },
+ data: {
+ status: WebhookCallStatus.IN_PROGRESS,
+ attempt,
+ },
+ });
+
+ // TODO: perform signed HTTP POST with backoff tracking and update status/metrics.
+ // This stub ensures call rows and queue wiring exist before adding delivery logic.
+ const lockKey = `webhook:lock:${call.webhookId}`;
+ const redis = getRedis();
+ const lockValue = randomUUID();
+
+ const lockAcquired = await acquireLock(redis, lockKey, lockValue);
+ if (!lockAcquired) {
+ await db.webhookCall.update({
+ where: { id: call.id },
+ data: {
+ nextAttemptAt: new Date(Date.now() + WEBHOOK_LOCK_RETRY_DELAY_MS),
+ status: WebhookCallStatus.PENDING,
+ },
+ });
+ // Let BullMQ handle retry timing; this records observability.
+ throw new Error("Webhook lock not acquired");
+ }
+
+ try {
+ const body = buildPayload(call, attempt);
+ const { responseStatus, responseTimeMs, responseText } = await postWebhook({
+ url: call.webhook.url,
+ secret: call.webhook.secret,
+ type: call.type,
+ callId: call.id,
+ body,
+ });
+
+ logger.info(
+ `Webhook call ${call.id} completed successfully, response status: ${responseStatus}, response time: ${responseTimeMs}ms, `,
+ );
+
+ await db.$transaction([
+ db.webhookCall.update({
+ where: { id: call.id },
+ data: {
+ status: WebhookCallStatus.DELIVERED,
+ attempt,
+ responseStatus,
+ responseTimeMs,
+ lastError: null,
+ nextAttemptAt: null,
+ responseText,
+ },
+ }),
+ db.webhook.update({
+ where: { id: call.webhookId },
+ data: {
+ consecutiveFailures: 0,
+ lastSuccessAt: new Date(),
+ },
+ }),
+ ]);
+ } catch (error) {
+ const errorMessage =
+ error instanceof Error ? error.message : "Unknown webhook error";
+ const responseStatus =
+ error instanceof WebhookHttpError ? error.statusCode : null;
+ const responseTimeMs =
+ error instanceof WebhookHttpError ? error.responseTimeMs : null;
+ const responseText =
+ error instanceof WebhookHttpError ? error.responseText : null;
+
+ const nextAttemptAt =
+ attempt < WEBHOOK_MAX_ATTEMPTS
+ ? new Date(Date.now() + computeBackoff(attempt))
+ : null;
+
+ const updatedWebhook = await db.webhook.update({
+ where: { id: call.webhookId },
+ data: {
+ consecutiveFailures: {
+ increment: 1,
+ },
+ lastFailureAt: new Date(),
+ status:
+ call.webhook.consecutiveFailures + 1 >= WEBHOOK_AUTO_DISABLE_THRESHOLD
+ ? WebhookStatus.AUTO_DISABLED
+ : call.webhook.status,
+ },
+ });
+
+ await db.webhookCall.update({
+ where: { id: call.id },
+ data: {
+ status:
+ attempt >= WEBHOOK_MAX_ATTEMPTS
+ ? WebhookCallStatus.FAILED
+ : WebhookCallStatus.PENDING,
+ attempt,
+ nextAttemptAt,
+ lastError: errorMessage,
+ responseStatus: responseStatus ?? undefined,
+ responseTimeMs: responseTimeMs ?? undefined,
+ responseText: responseText ?? undefined,
+ },
+ });
+
+ const statusLabel =
+ updatedWebhook.status === WebhookStatus.AUTO_DISABLED
+ ? "auto-disabled"
+ : "failed";
+
+ logger.warn(
+ {
+ callId: call.id,
+ webhookId: call.webhookId,
+ statusLabel,
+ attempt,
+ responseStatus,
+ nextAttemptAt,
+ error: errorMessage,
+ },
+ "[WebhookQueueService]: Webhook call failure",
+ );
+
+ if (updatedWebhook.status === WebhookStatus.AUTO_DISABLED) {
+ return;
+ }
+
+ throw error;
+ } finally {
+ await releaseLock(redis, lockKey, lockValue);
+ }
+}
+
+async function acquireLock(
+ redis: ReturnType,
+ key: string,
+ value: string,
+) {
+ const result = await redis.set(key, value, "PX", WEBHOOK_LOCK_TTL_MS, "NX");
+ return result === "OK";
+}
+
+async function releaseLock(
+ redis: ReturnType,
+ key: string,
+ value: string,
+) {
+ const script = `
+ if redis.call("GET", KEYS[1]) == ARGV[1] then
+ return redis.call("DEL", KEYS[1])
+ else
+ return 0
+ end
+ `;
+ try {
+ await redis.eval(script, 1, key, value);
+ } catch (error) {
+ logger.error({ error }, "[WebhookQueueService]: Failed to release lock");
+ }
+}
+
+function computeBackoff(attempt: number) {
+ const base = WEBHOOK_BASE_BACKOFF_MS * Math.pow(2, attempt - 1);
+ const jitter = base * 0.3 * Math.random();
+ return base + jitter;
+}
+
+type WebhookPayload = {
+ id: string;
+ type: string;
+ version: string | null;
+ createdAt: string;
+ teamId: number;
+ data: unknown;
+ attempt: number;
+};
+
+function buildPayload(
+ call: {
+ id: string;
+ webhookId: string;
+ teamId: number;
+ type: string;
+ payload: string;
+ createdAt: Date;
+ webhook: { apiVersion: string | null };
+ },
+ attempt: number,
+): WebhookPayload {
+ let parsed: unknown = call.payload;
+ try {
+ parsed = JSON.parse(call.payload);
+ } catch {
+ // keep string payload as-is
+ }
+
+ return {
+ id: call.id,
+ type: call.type,
+ version: call.webhook.apiVersion ?? WEBHOOK_EVENT_VERSION,
+ createdAt: call.createdAt.toISOString(),
+ teamId: call.teamId,
+ data: parsed,
+ attempt,
+ };
+}
+
+class WebhookHttpError extends Error {
+ public statusCode: number | null;
+ public responseTimeMs: number | null;
+ public responseText: string | null;
+
+ constructor(
+ message: string,
+ statusCode: number | null,
+ responseTimeMs: number | null,
+ responseText: string | null,
+ ) {
+ super(message);
+ this.statusCode = statusCode;
+ this.responseTimeMs = responseTimeMs;
+ this.responseText = responseText;
+ }
+}
+
+async function postWebhook(params: {
+ url: string;
+ secret: string;
+ type: string;
+ callId: string;
+ body: WebhookPayload;
+}) {
+ const controller = new AbortController();
+ const timeout = setTimeout(
+ () => controller.abort(),
+ WEBHOOK_REQUEST_TIMEOUT_MS,
+ );
+
+ const stringBody = JSON.stringify(params.body);
+ const timestamp = Date.now().toString();
+ const signature = signBody(params.secret, timestamp, stringBody);
+
+ const headers = {
+ "Content-Type": "application/json",
+ "User-Agent": "UseSend-Webhook/1.0",
+ "X-UseSend-Event": params.type,
+ "X-UseSend-Call": params.callId,
+ "X-UseSend-Timestamp": timestamp,
+ "X-UseSend-Signature": signature,
+ "X-UseSend-Retry": params.body.attempt > 1 ? "true" : "false",
+ };
+
+ const start = Date.now();
+
+ try {
+ const response = await fetch(params.url, {
+ method: "POST",
+ headers,
+ body: stringBody,
+ redirect: "manual",
+ signal: controller.signal,
+ });
+
+ const responseTimeMs = Date.now() - start;
+ const responseText = await captureResponseText(response);
+ if (response.ok) {
+ return {
+ responseStatus: response.status,
+ responseTimeMs,
+ responseText,
+ };
+ }
+
+ throw new WebhookHttpError(
+ `Non-2xx response: ${response.status}`,
+ response.status,
+ responseTimeMs,
+ responseText,
+ );
+ } catch (error) {
+ const responseTimeMs = Date.now() - start;
+ if (error instanceof WebhookHttpError) {
+ throw error;
+ }
+ if (error instanceof DOMException && error.name === "AbortError") {
+ throw new WebhookHttpError(
+ "Webhook request timed out",
+ null,
+ responseTimeMs,
+ null,
+ );
+ }
+ throw new WebhookHttpError(
+ error instanceof Error ? error.message : "Unknown fetch error",
+ null,
+ responseTimeMs,
+ null,
+ );
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+function signBody(secret: string, timestamp: string, body: string) {
+ const hmac = createHmac("sha256", secret);
+ hmac.update(`${timestamp}.${body}`);
+ return `v1=${hmac.digest("hex")}`;
+}
+
+async function captureResponseText(response: Response) {
+ const contentType = response.headers.get("content-type");
+ const isText =
+ contentType?.startsWith("text/") ||
+ contentType?.includes("application/json") ||
+ contentType?.includes("application/xml");
+
+ if (!isText) {
+ return null;
+ }
+
+ const contentLengthHeader = response.headers.get("content-length");
+ const contentLength = contentLengthHeader
+ ? Number.parseInt(contentLengthHeader, 10)
+ : null;
+
+ if (contentLength && Number.isFinite(contentLength)) {
+ if (contentLength <= 0) {
+ return "";
+ }
+ if (contentLength > WEBHOOK_RESPONSE_TEXT_LIMIT * 2) {
+ return ``;
+ }
+ }
+
+ const body = response.body;
+
+ if (body && typeof body.getReader === "function") {
+ const reader = body.getReader();
+ const decoder = new TextDecoder();
+ let received = 0;
+ let chunks = "";
+ let truncated = false;
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+
+ if (value) {
+ const decoded = decoder.decode(value, { stream: true });
+ received += decoded.length;
+ if (received > WEBHOOK_RESPONSE_TEXT_LIMIT) {
+ const sliceRemaining =
+ WEBHOOK_RESPONSE_TEXT_LIMIT - (received - decoded.length);
+ chunks += decoded.slice(0, Math.max(0, sliceRemaining));
+ truncated = true;
+ await reader.cancel();
+ break;
+ } else {
+ chunks += decoded;
+ }
+ }
+ }
+
+ if (truncated) {
+ return `${chunks}...`;
+ }
+
+ return chunks;
+ }
+
+ const text = await response.text();
+ if (text.length > WEBHOOK_RESPONSE_TEXT_LIMIT) {
+ return `${text.slice(0, WEBHOOK_RESPONSE_TEXT_LIMIT)}...`;
+ }
+
+ return text;
+}
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index 4e1841b6..5736661b 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -11,5 +11,6 @@ export default {
"./src/**/*.tsx",
path.join(here, "../../packages/ui/src/**/*.{ts,tsx}"),
path.join(here, "../../packages/email-editor/src/**/*.{ts,tsx}"),
+ path.join(here, "../../packages/lib/src/**/*.{ts,tsx}"),
],
} satisfies Config;
diff --git a/docker/dev/compose.yml b/docker/dev/compose.yml
index a12b91ac..8907163e 100644
--- a/docker/dev/compose.yml
+++ b/docker/dev/compose.yml
@@ -25,7 +25,7 @@ services:
command: ["redis-server", "--maxmemory-policy", "noeviction"]
local-sen-sns:
- image: unsend/local-ses-sns:latest
+ image: usesend/local-ses-sns:latest
container_name: local-ses-sns
restart: always
ports:
diff --git a/packages/lib/.eslintrc.cjs b/packages/lib/.eslintrc.cjs
new file mode 100644
index 00000000..10592991
--- /dev/null
+++ b/packages/lib/.eslintrc.cjs
@@ -0,0 +1,10 @@
+/** @type {import("eslint").Linter.Config} */
+module.exports = {
+ root: true,
+ extends: ["@usesend/eslint-config/library.js"],
+ parser: "@typescript-eslint/parser",
+ parserOptions: {
+ project: "./tsconfig.lint.json",
+ tsconfigRootDir: __dirname,
+ },
+};
diff --git a/packages/lib/index.ts b/packages/lib/index.ts
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/lib/package.json b/packages/lib/package.json
new file mode 100644
index 00000000..3c2d96e2
--- /dev/null
+++ b/packages/lib/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "@usesend/lib",
+ "version": "0.0.0",
+ "private": true,
+ "main": "./index.ts",
+ "types": "./index.ts",
+ "files": [
+ "src"
+ ],
+ "scripts": {
+ "lint": "eslint . --max-warnings 0",
+ "lint:fix": "eslint . --fix"
+ },
+ "devDependencies": {
+ "@types/node": "^22.15.2",
+ "@usesend/eslint-config": "workspace:*",
+ "@usesend/typescript-config": "workspace:*",
+ "eslint": "^8.57.1",
+ "prettier": "^3.5.3",
+ "typescript": "^5.8.3"
+ }
+}
diff --git a/apps/web/src/lib/constants/ses-errors.ts b/packages/lib/src/constants/ses-errors.ts
similarity index 100%
rename from apps/web/src/lib/constants/ses-errors.ts
rename to packages/lib/src/constants/ses-errors.ts
diff --git a/packages/lib/src/index.ts b/packages/lib/src/index.ts
new file mode 100644
index 00000000..0ba3373f
--- /dev/null
+++ b/packages/lib/src/index.ts
@@ -0,0 +1,22 @@
+export function invariant(
+ condition: unknown,
+ message = "Invariant failed"
+): asserts condition {
+ if (!condition) {
+ throw new Error(message);
+ }
+}
+
+export function assertUnreachable(value: never): never {
+ throw new Error(`Reached unreachable code with value: ${String(value)}`);
+}
+
+export const isDefined = (
+ value: T | null | undefined
+): value is T => value !== null && value !== undefined;
+
+export {
+ BOUNCE_ERROR_MESSAGES,
+ COMPLAINT_ERROR_MESSAGES,
+ DELIVERY_DELAY_ERRORS,
+} from "./constants/ses-errors";
diff --git a/packages/lib/src/webhook/webhook-events.ts b/packages/lib/src/webhook/webhook-events.ts
new file mode 100644
index 00000000..a9deac95
--- /dev/null
+++ b/packages/lib/src/webhook/webhook-events.ts
@@ -0,0 +1,196 @@
+export const ContactEvents = [
+ "contact.created",
+ "contact.updated",
+ "contact.deleted",
+] as const;
+
+export type ContactWebhookEventType = (typeof ContactEvents)[number];
+
+export const DomainEvents = [
+ "domain.created",
+ "domain.verified",
+ "domain.updated",
+ "domain.deleted",
+] as const;
+
+export type DomainWebhookEventType = (typeof DomainEvents)[number];
+
+export const EmailEvents = [
+ "email.queued",
+ "email.sent",
+ "email.delivery_delayed",
+ "email.delivered",
+ "email.bounced",
+ "email.rejected",
+ "email.rendering_failure",
+ "email.complained",
+ "email.failed",
+ "email.cancelled",
+ "email.suppressed",
+ "email.opened",
+ "email.clicked",
+] as const;
+
+export type EmailWebhookEventType = (typeof EmailEvents)[number];
+
+export const WebhookEvents = [
+ ...ContactEvents,
+ ...DomainEvents,
+ ...EmailEvents,
+] as const;
+
+export type WebhookEventType = (typeof WebhookEvents)[number];
+
+export type EmailStatus =
+ | "QUEUED"
+ | "SENT"
+ | "DELIVERY_DELAYED"
+ | "DELIVERED"
+ | "BOUNCED"
+ | "REJECTED"
+ | "RENDERING_FAILURE"
+ | "COMPLAINED"
+ | "FAILED"
+ | "CANCELLED"
+ | "SUPPRESSED"
+ | "OPENED"
+ | "CLICKED"
+ | "SCHEDULED";
+
+export type EmailBasePayload = {
+ id: string;
+ status: EmailStatus;
+ from: string;
+ to: Array;
+ occurredAt: string;
+ campaignId?: string | null;
+ contactId?: string | null;
+ domainId?: number | null;
+ subject?: string;
+ templateId?: string;
+ metadata?: Record;
+};
+
+export type ContactPayload = {
+ id: string;
+ email: string;
+ contactBookId: string;
+ subscribed: boolean;
+ properties: Record;
+ firstName?: string | null;
+ lastName?: string | null;
+ createdAt: string;
+ updatedAt: string;
+};
+
+export type DomainPayload = {
+ id: number;
+ name: string;
+ status: string;
+ region: string;
+ createdAt: string;
+ updatedAt: string;
+ clickTracking: boolean;
+ openTracking: boolean;
+ subdomain?: string | null;
+ sesTenantId?: string | null;
+ dkimStatus?: string | null;
+ spfDetails?: string | null;
+ dmarcAdded?: boolean | null;
+};
+
+export type EmailBouncedPayload = EmailBasePayload & {
+ bounce: {
+ type: "Transient" | "Permanent" | "Undetermined";
+ subType:
+ | "General"
+ | "NoEmail"
+ | "Suppressed"
+ | "OnAccountSuppressionList"
+ | "MailboxFull"
+ | "MessageTooLarge"
+ | "ContentRejected"
+ | "AttachmentRejected";
+ message?: string;
+ };
+};
+
+export type EmailFailedPayload = EmailBasePayload & {
+ failed: {
+ reason: string;
+ };
+};
+
+export type EmailSuppressedPayload = EmailBasePayload & {
+ suppression: {
+ type: "Bounce" | "Complaint" | "Manual";
+ reason: string;
+ source?: string;
+ };
+};
+
+export type EmailOpenedPayload = EmailBasePayload & {
+ open: {
+ timestamp: string;
+ userAgent?: string;
+ ip?: string;
+ platform?: string;
+ };
+};
+
+export type EmailClickedPayload = EmailBasePayload & {
+ click: {
+ timestamp: string;
+ url: string;
+ userAgent?: string;
+ ip?: string;
+ platform?: string;
+ };
+};
+
+export type EmailEventPayloadMap = {
+ "email.queued": EmailBasePayload;
+ "email.sent": EmailBasePayload;
+ "email.delivery_delayed": EmailBasePayload;
+ "email.delivered": EmailBasePayload;
+ "email.bounced": EmailBouncedPayload;
+ "email.rejected": EmailBasePayload;
+ "email.rendering_failure": EmailBasePayload;
+ "email.complained": EmailBasePayload;
+ "email.failed": EmailFailedPayload;
+ "email.cancelled": EmailBasePayload;
+ "email.suppressed": EmailSuppressedPayload;
+ "email.opened": EmailOpenedPayload;
+ "email.clicked": EmailClickedPayload;
+};
+
+export type DomainEventPayloadMap = {
+ "domain.created": DomainPayload;
+ "domain.verified": DomainPayload;
+ "domain.updated": DomainPayload;
+ "domain.deleted": DomainPayload;
+};
+
+export type ContactEventPayloadMap = {
+ "contact.created": ContactPayload;
+ "contact.updated": ContactPayload;
+ "contact.deleted": ContactPayload;
+};
+
+export type WebhookEventPayloadMap = EmailEventPayloadMap &
+ DomainEventPayloadMap &
+ ContactEventPayloadMap;
+
+export type WebhookPayloadData =
+ WebhookEventPayloadMap[TType];
+
+export type WebhookEvent = {
+ id: string;
+ type: TType;
+ createdAt: string;
+ data: WebhookPayloadData;
+};
+
+export type WebhookEventData = {
+ [T in WebhookEventType]: WebhookEvent;
+}[WebhookEventType];
diff --git a/packages/lib/tsconfig.json b/packages/lib/tsconfig.json
new file mode 100644
index 00000000..f2ef626e
--- /dev/null
+++ b/packages/lib/tsconfig.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@usesend/typescript-config/base.json",
+ "compilerOptions": {
+ "outDir": "dist"
+ },
+ "include": ["src/**/*.ts", "src/**/*.tsx"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/lib/tsconfig.lint.json b/packages/lib/tsconfig.lint.json
new file mode 100644
index 00000000..26e80d06
--- /dev/null
+++ b/packages/lib/tsconfig.lint.json
@@ -0,0 +1,8 @@
+{
+ "extends": "@usesend/typescript-config/base.json",
+ "compilerOptions": {
+ "outDir": "dist"
+ },
+ "include": ["src", "turbo", "**/*.ts", "**/*.tsx"],
+ "exclude": ["node_modules", "dist"]
+}
diff --git a/packages/sdk/README.md b/packages/sdk/README.md
index d42f8170..60f3a3dc 100644
--- a/packages/sdk/README.md
+++ b/packages/sdk/README.md
@@ -115,3 +115,79 @@ await usesend.campaigns.pause(campaign.data.id);
// Resume a campaign
await usesend.campaigns.resume(campaign.data.id);
```
+
+## Webhooks
+
+Verify webhook signatures and get typed events:
+
+```ts
+import { constructEvent } from "usesend";
+
+// In a Next.js App Route
+export async function POST(request: Request) {
+ try {
+ const rawBody = await request.text(); // important: raw body, not parsed JSON
+ const event = constructEvent({
+ secret: process.env.USESEND_WEBHOOK_SECRET!,
+ headers: request.headers,
+ rawBody,
+ });
+
+ if (event.type === "email.delivered") {
+ // event.data is strongly typed here
+ }
+
+ return new Response("ok");
+ } catch (error) {
+ return new Response((error as Error).message, { status: 400 });
+ }
+}
+```
+
+Need only signature verification? You can call it directly:
+
+```ts
+import { verifyWebhookSignature } from "usesend";
+
+verifyWebhookSignature({
+ secret: process.env.USESEND_WEBHOOK_SECRET!,
+ rawBody,
+ signatureHeader: request.headers.get("X-UseSend-Signature"),
+ timestampHeader: request.headers.get("X-UseSend-Timestamp"),
+});
+```
+
+Express example (ensure raw body is preserved):
+
+```ts
+import express from "express";
+import { constructEvent } from "usesend";
+
+const app = express();
+app.post("/webhook", express.raw({ type: "application/json" }), (req, res) => {
+ try {
+ const event = constructEvent({
+ secret: process.env.USESEND_WEBHOOK_SECRET!,
+ headers: req.headers,
+ rawBody: req.body,
+ });
+
+ if (event.type === "email.bounced") {
+ // handle bounce
+ }
+
+ res.status(200).send("ok");
+ } catch (error) {
+ res.status(400).send((error as Error).message);
+ }
+});
+```
+
+Headers sent by UseSend:
+
+- `X-UseSend-Signature`: `v1=` + HMAC-SHA256 of `${timestamp}.${rawBody}`
+- `X-UseSend-Timestamp`: Unix epoch in milliseconds
+- `X-UseSend-Event`: webhook event type
+- `X-UseSend-Call`: unique webhook attempt id
+
+By default, signatures are only accepted within 5 minutes of the timestamp. Override with `toleranceMs` if needed.
diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts
index 2b8da96d..e4482443 100644
--- a/packages/sdk/index.ts
+++ b/packages/sdk/index.ts
@@ -1,3 +1,17 @@
export { UseSend } from "./src/usesend";
export { UseSend as Unsend } from "./src/usesend"; // deprecated alias
export { Campaigns } from "./src/campaign";
+export {
+ Webhooks,
+ WebhookVerificationError,
+ WEBHOOK_EVENT_HEADER,
+ WEBHOOK_CALL_HEADER,
+ WEBHOOK_SIGNATURE_HEADER,
+ WEBHOOK_TIMESTAMP_HEADER,
+} from "./src/webhooks";
+export type {
+ WebhookEvent,
+ WebhookEventData,
+ WebhookEventPayloadMap,
+ WebhookEventType,
+} from "./src/webhooks";
diff --git a/packages/sdk/package.json b/packages/sdk/package.json
index 25a6b438..b30ed123 100644
--- a/packages/sdk/package.json
+++ b/packages/sdk/package.json
@@ -8,7 +8,7 @@
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"lint": "eslint . --max-warnings 0",
- "build": "rm -rf dist && tsup index.ts --format esm,cjs --dts",
+ "build": "rm -rf dist && tsup index.ts --format esm,cjs --dts --noExternal @usesend/lib",
"publish-sdk": "pnpm run build && pnpm publish --no-git-checks",
"openapi-typegen": "openapi-typescript ../../apps/docs/api-reference/openapi.json -o types/schema.d.ts"
},
@@ -19,6 +19,7 @@
"@types/node": "^22.15.2",
"@types/react": "^19.1.2",
"@usesend/eslint-config": "workspace:*",
+ "@usesend/lib": "workspace:*",
"@usesend/typescript-config": "workspace:*",
"openapi-typescript": "^7.6.1",
"tsup": "^8.4.0",
diff --git a/packages/sdk/src/usesend.ts b/packages/sdk/src/usesend.ts
index 8dfafe24..682c5966 100644
--- a/packages/sdk/src/usesend.ts
+++ b/packages/sdk/src/usesend.ts
@@ -3,6 +3,7 @@ import { Contacts } from "./contact";
import { Emails } from "./email";
import { Domains } from "./domain";
import { Campaigns } from "./campaign";
+import { Webhooks } from "./webhooks";
const defaultBaseUrl = "https://app.usesend.com";
// eslint-disable-next-line turbo/no-undeclared-env-vars
@@ -19,7 +20,6 @@ type RequestOptions = {
export class UseSend {
private readonly baseHeaders: Headers;
- // readonly domains = new Domains(this);
readonly emails = new Emails(this);
readonly domains = new Domains(this);
readonly contacts = new Contacts(this);
@@ -171,4 +171,26 @@ export class UseSend {
return this.fetchRequest(path, requestOptions);
}
+
+ /**
+ * Creates a webhook handler with the given secret.
+ * Follows the Stripe pattern: `usesend.webhooks(secret).constructEvent(...)`
+ *
+ * @param secret - Webhook signing secret from your UseSend dashboard
+ * @returns Webhooks instance for verifying webhook events
+ *
+ * @example
+ * ```ts
+ * const usesend = new UseSend('us_xxx');
+ * const webhooks = usesend.webhooks('whsec_xxx');
+ *
+ * // In your webhook route
+ * const event = webhooks.constructEvent(req.body, {
+ * headers: req.headers
+ * });
+ * ```
+ */
+ webhooks(secret: string): Webhooks {
+ return new Webhooks(secret);
+ }
}
diff --git a/packages/sdk/src/webhooks.ts b/packages/sdk/src/webhooks.ts
new file mode 100644
index 00000000..6d212aaf
--- /dev/null
+++ b/packages/sdk/src/webhooks.ts
@@ -0,0 +1,279 @@
+import { createHmac, timingSafeEqual } from "crypto";
+import type {
+ WebhookEvent,
+ WebhookEventData,
+ WebhookEventPayloadMap,
+ WebhookEventType,
+} from "@usesend/lib/src/webhook/webhook-events";
+
+type RawBody = string | Buffer | ArrayBuffer | ArrayBufferView | Uint8Array;
+
+type HeaderLike =
+ | Headers
+ | Record
+ | undefined
+ | null;
+
+export type WebhookVerificationErrorCode =
+ | "MISSING_SIGNATURE"
+ | "MISSING_TIMESTAMP"
+ | "INVALID_SIGNATURE_FORMAT"
+ | "INVALID_TIMESTAMP"
+ | "TIMESTAMP_OUT_OF_RANGE"
+ | "SIGNATURE_MISMATCH"
+ | "INVALID_BODY"
+ | "INVALID_JSON";
+
+export class WebhookVerificationError extends Error {
+ constructor(
+ public readonly code: WebhookVerificationErrorCode,
+ message: string,
+ ) {
+ super(message);
+ this.name = "WebhookVerificationError";
+ }
+}
+
+export const WEBHOOK_SIGNATURE_HEADER = "X-UseSend-Signature";
+export const WEBHOOK_TIMESTAMP_HEADER = "X-UseSend-Timestamp";
+export const WEBHOOK_EVENT_HEADER = "X-UseSend-Event";
+export const WEBHOOK_CALL_HEADER = "X-UseSend-Call";
+
+const SIGNATURE_PREFIX = "v1=";
+const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;
+
+export class Webhooks {
+ constructor(private secret: string) {}
+
+ /**
+ * Verify webhook signature without parsing the event.
+ *
+ * @param body - Raw webhook body (string or Buffer)
+ * @param options - Headers and optional configuration
+ * @returns true if signature is valid, false otherwise
+ *
+ * @example
+ * ```ts
+ * const usesend = new UseSend(apiKey);
+ * const webhooks = usesend.webhooks('whsec_xxx');
+ *
+ * const isValid = webhooks.verify(body, {
+ * headers: request.headers
+ * });
+ *
+ * if (!isValid) {
+ * return new Response('Invalid signature', { status: 401 });
+ * }
+ * ```
+ */
+ verify(
+ body: RawBody,
+ options: {
+ headers: HeaderLike;
+ secret?: string;
+ tolerance?: number;
+ },
+ ): boolean {
+ try {
+ this.verifyInternal(body, options);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ /**
+ * Verify and parse a webhook event.
+ *
+ * @param body - Raw webhook body (string or Buffer)
+ * @param options - Headers and optional configuration
+ * @returns Verified and typed webhook event
+ *
+ * @example
+ * ```ts
+ * const usesend = new UseSend(apiKey);
+ * const webhooks = usesend.webhooks('whsec_xxx');
+ *
+ * // Next.js App Router
+ * const event = webhooks.constructEvent(await request.text(), {
+ * headers: request.headers
+ * });
+ *
+ * // Next.js Pages Router
+ * const event = webhooks.constructEvent(req.body, {
+ * headers: req.headers
+ * });
+ *
+ * // Express
+ * const event = webhooks.constructEvent(req.body, {
+ * headers: req.headers
+ * });
+ *
+ * // Type-safe event handling
+ * if (event.type === 'email.delivered') {
+ * console.log(event.data.to);
+ * }
+ * ```
+ */
+ constructEvent(
+ body: RawBody,
+ options: {
+ headers: HeaderLike;
+ secret?: string;
+ tolerance?: number;
+ },
+ ): WebhookEventData {
+ this.verifyInternal(body, options);
+
+ const bodyString = toUtf8String(body);
+ try {
+ return JSON.parse(bodyString) as WebhookEventData;
+ } catch {
+ throw new WebhookVerificationError(
+ "INVALID_JSON",
+ "Webhook payload is not valid JSON",
+ );
+ }
+ }
+
+ private verifyInternal(
+ body: RawBody,
+ options: {
+ headers: HeaderLike;
+ secret?: string;
+ tolerance?: number;
+ },
+ ): void {
+ const webhookSecret = options.secret ?? this.secret;
+ const signature = getHeader(options.headers, WEBHOOK_SIGNATURE_HEADER);
+ const timestamp = getHeader(options.headers, WEBHOOK_TIMESTAMP_HEADER);
+
+ if (!signature) {
+ throw new WebhookVerificationError(
+ "MISSING_SIGNATURE",
+ `Missing ${WEBHOOK_SIGNATURE_HEADER} header`,
+ );
+ }
+
+ if (!timestamp) {
+ throw new WebhookVerificationError(
+ "MISSING_TIMESTAMP",
+ `Missing ${WEBHOOK_TIMESTAMP_HEADER} header`,
+ );
+ }
+
+ if (!signature.startsWith(SIGNATURE_PREFIX)) {
+ throw new WebhookVerificationError(
+ "INVALID_SIGNATURE_FORMAT",
+ "Signature header must start with v1=",
+ );
+ }
+
+ const timestampNum = Number(timestamp);
+ if (!Number.isFinite(timestampNum)) {
+ throw new WebhookVerificationError(
+ "INVALID_TIMESTAMP",
+ "Timestamp header must be a number (milliseconds since epoch)",
+ );
+ }
+
+ const toleranceMs = options.tolerance ?? DEFAULT_TOLERANCE_MS;
+ const now = Date.now();
+ if (toleranceMs >= 0 && Math.abs(now - timestampNum) > toleranceMs) {
+ throw new WebhookVerificationError(
+ "TIMESTAMP_OUT_OF_RANGE",
+ "Webhook timestamp is outside the allowed tolerance",
+ );
+ }
+
+ const bodyString = toUtf8String(body);
+ const expected = computeSignature(webhookSecret, timestamp, bodyString);
+
+ if (!safeEqual(expected, signature)) {
+ throw new WebhookVerificationError(
+ "SIGNATURE_MISMATCH",
+ "Webhook signature does not match",
+ );
+ }
+ }
+}
+
+function computeSignature(secret: string, timestamp: string, body: string) {
+ const hmac = createHmac("sha256", secret);
+ hmac.update(`${timestamp}.${body}`);
+ return `${SIGNATURE_PREFIX}${hmac.digest("hex")}`;
+}
+
+function toUtf8String(body: RawBody): string {
+ if (typeof body === "string") {
+ return body;
+ }
+
+ if (Buffer.isBuffer(body)) {
+ return body.toString("utf8");
+ }
+
+ if (body instanceof ArrayBuffer) {
+ return Buffer.from(body).toString("utf8");
+ }
+
+ if (ArrayBuffer.isView(body)) {
+ return Buffer.from(body.buffer, body.byteOffset, body.byteLength).toString(
+ "utf8",
+ );
+ }
+
+ throw new WebhookVerificationError(
+ "INVALID_BODY",
+ "Unsupported raw body type",
+ );
+}
+
+function getHeader(headers: HeaderLike, name: string): string | null {
+ if (!headers) {
+ return null;
+ }
+
+ if (typeof (headers as Headers).get === "function") {
+ const headerValue = (headers as Headers).get(name);
+ if (headerValue !== null) {
+ return headerValue;
+ }
+ }
+
+ const lowerName = name.toLowerCase();
+ const record = headers as Record;
+ const matchingKey = Object.keys(record).find(
+ (key) => key.toLowerCase() === lowerName,
+ );
+
+ if (!matchingKey) {
+ return null;
+ }
+
+ const value = record[matchingKey];
+
+ if (Array.isArray(value)) {
+ return value[0] ?? null;
+ }
+
+ return value ?? null;
+}
+
+function safeEqual(a: string, b: string) {
+ const aBuf = Buffer.from(a, "utf8");
+ const bBuf = Buffer.from(b, "utf8");
+
+ if (aBuf.length !== bBuf.length) {
+ return false;
+ }
+
+ return timingSafeEqual(aBuf, bBuf);
+}
+
+export type {
+ WebhookEvent,
+ WebhookEventData,
+ WebhookEventPayloadMap,
+ WebhookEventType,
+};
diff --git a/packages/ui/src/dropdown-menu.tsx b/packages/ui/src/dropdown-menu.tsx
index 5c4c76bb..a1d670d2 100644
--- a/packages/ui/src/dropdown-menu.tsx
+++ b/packages/ui/src/dropdown-menu.tsx
@@ -29,7 +29,7 @@ const DropdownMenuSubTrigger = React.forwardRef<
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
- className
+ className,
)}
{...props}
>
@@ -48,7 +48,7 @@ const DropdownMenuSubContent = React.forwardRef<
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
- className
+ className,
)}
{...props}
/>
@@ -66,7 +66,7 @@ const DropdownMenuContent = React.forwardRef<
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-xl border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
- className
+ className,
)}
{...props}
/>
@@ -85,7 +85,7 @@ const DropdownMenuItem = React.forwardRef<
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
- className
+ className,
)}
{...props}
/>
@@ -99,18 +99,18 @@ const DropdownMenuCheckboxItem = React.forwardRef<
-
+ {children}
+
- {children}
));
DropdownMenuCheckboxItem.displayName =
@@ -124,7 +124,7 @@ const DropdownMenuRadioItem = React.forwardRef<
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
- className
+ className,
)}
{...props}
>
@@ -149,7 +149,7 @@ const DropdownMenuLabel = React.forwardRef<
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
- className
+ className,
)}
{...props}
/>
diff --git a/packages/ui/styles/globals.css b/packages/ui/styles/globals.css
index 711a5a5c..2bae8a00 100644
--- a/packages/ui/styles/globals.css
+++ b/packages/ui/styles/globals.css
@@ -145,21 +145,24 @@
font-style: normal !important;
}
-@media (prefers-color-scheme: dark) {
- .shiki,
- .shiki span {
- color: var(--shiki-dark) !important;
- background-color: var(--shiki-dark-bg) !important;
- /* Optional, if you also want font styles */
- font-weight: var(--shiki-dark-font-weight) !important;
- text-decoration: var(--shiki-dark-text-decoration) !important;
- }
+.dark .shiki,
+.dark .shiki span {
+ color: var(--shiki-dark) !important;
+ background-color: var(--shiki-dark-bg) !important;
+ /* Optional, if you also want font styles */
+ font-weight: var(--shiki-dark-font-weight) !important;
+ text-decoration: var(--shiki-dark-text-decoration) !important;
}
@layer utilities {
/* Hide scrollbars but preserve scroll behavior */
- .no-scrollbar::-webkit-scrollbar { display: none; }
- .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
+ }
+ .no-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
+ }
}
/* .app,
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 88867c2a..94fa2908 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -206,6 +206,9 @@ importers:
'@usesend/email-editor':
specifier: workspace:*
version: link:../../packages/email-editor
+ '@usesend/lib':
+ specifier: workspace:*
+ version: link:../../packages/lib
'@usesend/ui':
specifier: workspace:*
version: link:../../packages/ui
@@ -518,6 +521,27 @@ importers:
specifier: ^5.8.3
version: 5.8.3
+ packages/lib:
+ devDependencies:
+ '@types/node':
+ specifier: ^22.15.2
+ version: 22.15.2
+ '@usesend/eslint-config':
+ specifier: workspace:*
+ version: link:../eslint-config
+ '@usesend/typescript-config':
+ specifier: workspace:*
+ version: link:../typescript-config
+ eslint:
+ specifier: ^8.57.1
+ version: 8.57.1
+ prettier:
+ specifier: ^3.5.3
+ version: 3.5.3
+ typescript:
+ specifier: ^5.8.3
+ version: 5.8.3
+
packages/sdk:
dependencies:
'@react-email/render':
@@ -536,6 +560,9 @@ importers:
'@usesend/eslint-config':
specifier: workspace:*
version: link:../eslint-config
+ '@usesend/lib':
+ specifier: workspace:*
+ version: link:../lib
'@usesend/typescript-config':
specifier: workspace:*
version: link:../typescript-config
diff --git a/references/webhook-architecture.md b/references/webhook-architecture.md
new file mode 100644
index 00000000..ed3d28c3
--- /dev/null
+++ b/references/webhook-architecture.md
@@ -0,0 +1,433 @@
+# Webhook Architecture
+
+This document explains the webhook system architecture, including how events are emitted, queued, delivered, and displayed.
+
+## Architecture Diagram
+
+```
+┌─────────────────────────────────────────────────────────────────────────────────────┐
+│ EVENT SOURCES │
+├─────────────────────┬─────────────────────┬─────────────────────────────────────────┤
+│ Email Service │ Contact Service │ Domain Service │
+│ (SES callbacks) │ (CRUD operations) │ (verification, etc.) │
+└─────────┬───────────┴──────────┬──────────┴──────────────────┬──────────────────────┘
+ │ │ │
+ │ ▼ │
+ │ ┌───────────────────────┐ │
+ └────────►│ WebhookService.emit │◄─────────────────┘
+ │ (teamId, type, │
+ │ payload) │
+ └───────────┬───────────┘
+ │
+ ┌───────────▼───────────┐
+ │ Find Active Webhooks │
+ │ matching event type │
+ └───────────┬───────────┘
+ │
+ ┌─────────────────┼─────────────────┐
+ ▼ ▼ ▼
+ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
+ │ Webhook A │ │ Webhook B │ │ Webhook C │
+ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘
+ │ │ │
+ ▼ ▼ ▼
+┌─────────────────────────────────────────────────────────────────────────────────────┐
+│ PostgreSQL Database │
+│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
+│ │ WebhookCall (one per matching webhook) │ │
+│ │ ├── status: PENDING │ │
+│ │ ├── payload: { event data only } │ │
+│ │ └── attempt: 0 │ │
+│ └─────────────────────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────────────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────────────────────┐
+│ Redis + BullMQ │
+│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
+│ │ WEBHOOK_DISPATCH_QUEUE │ │
+│ │ ├── Job: { callId: "call_abc", teamId: 123 } │ │
+│ │ ├── Job: { callId: "call_def", teamId: 123 } │ │
+│ │ └── Job: { callId: "call_ghi", teamId: 456 } │ │
+│ └─────────────────────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────────────────┘
+ │
+ │ BullMQ Worker (concurrency: 25)
+ ▼
+ ┌───────────────────────┐
+ │ processWebhookCall │
+ └───────────┬───────────┘
+ │
+ ┌───────────▼───────────┐
+ │ Acquire Redis Lock │──────┐
+ │ (per webhook ID) │ │ Lock failed
+ └───────────┬───────────┘ │
+ │ Lock acquired ▼
+ │ ┌─────────────┐
+ ┌───────────▼──────┐ │ Retry later │
+ │ Check webhook │ └─────────────┘
+ │ status = ACTIVE? │
+ └───────────┬──────┘
+ Yes │ No
+ ┌───────────┘ └──────────────┐
+ ▼ ▼
+ ┌─────────────────┐ ┌─────────────────┐
+ │ buildPayload │ │ Mark call as │
+ │ (wrap event │ │ DISCARDED │
+ │ data) │ └─────────────────┘
+ └────────┬────────┘
+ │
+ ▼
+┌─────────────────────────────────────────────────────────────────────────────────────┐
+│ HTTP POST Request │
+│ ┌─────────────────────────────────────────────────────────────────────────────┐ │
+│ │ Headers: │ │
+│ │ ├── X-UseSend-Signature: v1= │ │
+│ │ ├── X-UseSend-Timestamp: 1705312200000 │ │
+│ │ ├── X-UseSend-Event: email.delivered │ │
+│ │ └── X-UseSend-Call: call_abc123 │ │
+│ │ │ │
+│ │ Body: { │ │
+│ │ "id": "call_abc123", │ │
+│ │ "type": "email.delivered", │ │
+│ │ "version": "2024-11-01", │ │
+│ │ "createdAt": "...", │ │
+│ │ "teamId": 123, │ │
+│ │ "data": { ... event payload ... }, │ │
+│ │ "attempt": 1 │ │
+│ │ } │ │
+│ └─────────────────────────────────────────────────────────────────────────────┘ │
+└─────────────────────────────────────────────────────────────────────────────────────┘
+ │
+ ┌───────────┴───────────┐
+ ▼ ▼
+ ┌─────────────┐ ┌─────────────┐
+ │ 2xx OK │ │ Non-2xx / │
+ │ │ │ Timeout │
+ └──────┬──────┘ └──────┬──────┘
+ │ │
+ ▼ ▼
+ ┌─────────────────┐ ┌─────────────────────┐
+ │ Mark DELIVERED │ │ Increment failures │
+ │ Reset failures │ │ attempt < 6? │
+ │ to 0 │ └──────────┬──────────┘
+ └─────────────────┘ Yes │ No
+ ┌──────────┘ └──────────┐
+ ▼ ▼
+ ┌─────────────────┐ ┌─────────────────┐
+ │ Mark PENDING │ │ Mark FAILED │
+ │ Schedule retry │ │ │
+ │ (exp. backoff) │ │ failures >= 30? │
+ └─────────────────┘ └────────┬────────┘
+ Yes │ No
+ ┌───────────┘ └────┐
+ ▼ ▼
+ ┌─────────────────┐ ┌──────────┐
+ │ AUTO_DISABLE │ │ Done │
+ │ webhook │ └──────────┘
+ └─────────────────┘
+```
+
+## Call Status State Machine
+
+```
+ ┌──────────────────────────────────────┐
+ │ │
+ ▼ │
+┌─────────┐ enqueue ┌───────────┐ worker picks up ┌─────────────────┐
+│ (start) │ ──────────►│ PENDING │ ─────────────────►│ IN_PROGRESS │
+└─────────┘ └───────────┘ └────────┬────────┘
+ ▲ │
+ │ ┌────────────┼────────────┐
+ │ │ │ │
+ │ retry (attempt<6) │ │ │
+ │ ▼ ▼ ▼
+ │ ┌───────────┐ ┌───────────┐ ┌───────────┐
+ └────────────│ (fail) │ │ SUCCESS │ │ WEBHOOK │
+ └─────┬─────┘ └─────┬─────┘ │ INACTIVE │
+ │ │ └─────┬─────┘
+ │ │ │
+ attempt >= 6 │ │
+ │ ▼ ▼
+ │ ┌───────────┐ ┌───────────┐
+ └─────►│ FAILED │ │ DISCARDED │
+ └───────────┘ └───────────┘
+```
+
+## Overview
+
+The webhook system allows users to receive real-time HTTP notifications when events occur (emails sent, contacts created, domains verified, etc.). The system is built with reliability in mind, featuring:
+
+- Asynchronous delivery via BullMQ
+- Exponential backoff with jitter for retries
+- Automatic webhook disabling after consecutive failures
+- Per-webhook locking to ensure ordered delivery
+- HMAC signature verification for security
+
+## Core Components
+
+### 1. Database Models
+
+Located in `apps/web/prisma/schema.prisma`:
+
+```
+Webhook
+├── id (cuid)
+├── teamId (FK → Team)
+├── url (endpoint URL)
+├── secret (signing key, prefixed with "whsec_")
+├── status (ACTIVE | PAUSED | AUTO_DISABLED)
+├── eventTypes (string[] - empty means all events)
+├── apiVersion (optional version string)
+├── consecutiveFailures (counter for auto-disable)
+├── lastFailureAt / lastSuccessAt (timestamps)
+└── createdByUserId (FK → User)
+
+WebhookCall
+├── id (cuid)
+├── webhookId (FK → Webhook)
+├── teamId (FK → Team)
+├── type (event type, e.g., "email.delivered")
+├── payload (JSON string - event data only)
+├── status (PENDING | IN_PROGRESS | DELIVERED | FAILED | DISCARDED)
+├── attempt (current attempt number)
+├── nextAttemptAt (scheduled retry time)
+├── lastError (error message if failed)
+├── responseStatus / responseTimeMs / responseText
+└── createdAt / updatedAt
+```
+
+### 2. Service Layer
+
+Located in `apps/web/src/server/service/webhook-service.ts`:
+
+- **WebhookService**: CRUD operations for webhooks and webhook calls
+- **WebhookQueueService**: BullMQ queue management for async delivery
+
+### 3. Event Types
+
+Defined in `packages/lib/src/webhook/webhook-events.ts`:
+
+```typescript
+// Contact events
+"contact.created" | "contact.updated" | "contact.deleted";
+
+// Domain events
+"domain.created" | "domain.verified" | "domain.updated" | "domain.deleted";
+
+// Email events
+"email.queued" |
+ "email.sent" |
+ "email.delivery_delayed" |
+ "email.delivered" |
+ "email.bounced" |
+ "email.rejected" |
+ "email.rendering_failure" |
+ "email.complained" |
+ "email.failed" |
+ "email.cancelled" |
+ "email.suppressed" |
+ "email.opened" |
+ "email.clicked";
+```
+
+## Webhook Flow
+
+### Step 1: Event Emission
+
+When an event occurs in the system, `WebhookService.emit()` is called:
+
+```typescript
+// Example: emitting an email.delivered event
+await WebhookService.emit(teamId, "email.delivered", {
+ id: email.id,
+ status: "DELIVERED",
+ from: email.from,
+ to: email.to,
+ occurredAt: new Date().toISOString(),
+ // ... other fields
+});
+```
+
+### Step 2: Webhook Matching & Call Creation
+
+`emit()` performs the following:
+
+1. Finds all ACTIVE webhooks for the team that subscribe to the event type
+2. Creates a `WebhookCall` record for each matching webhook (stores event data as `payload`)
+3. Enqueues the call ID to BullMQ for async processing
+
+```typescript
+// Webhook matching logic
+const activeWebhooks = await db.webhook.findMany({
+ where: {
+ teamId,
+ status: WebhookStatus.ACTIVE,
+ OR: [
+ { eventTypes: { has: type } }, // Subscribed to this event
+ { eventTypes: { isEmpty: true } }, // Subscribed to ALL events
+ ],
+ },
+});
+```
+
+### Step 3: Queue Processing
+
+The BullMQ worker (`processWebhookCall`) handles delivery:
+
+1. **Lock Acquisition**: Acquires a Redis lock per webhook to ensure ordered delivery
+2. **Status Check**: Skips if webhook is no longer ACTIVE (marks call as DISCARDED)
+3. **Payload Building**: Wraps the stored event data in the full payload structure
+4. **HTTP POST**: Sends signed request to the webhook URL
+5. **Result Handling**: Updates call status and webhook metrics
+
+### Step 4: Payload Structure
+
+**Important**: The stored `WebhookCall.payload` contains only the event data. The actual HTTP request body is built at delivery time by `buildPayload()`:
+
+```typescript
+// Stored in WebhookCall.payload (event data only):
+{
+ "id": "email_123",
+ "status": "DELIVERED",
+ "from": "sender@example.com",
+ "to": ["recipient@example.com"],
+ "occurredAt": "2024-01-15T10:30:00Z"
+}
+
+// Actual payload sent to webhook endpoint:
+{
+ "id": "call_abc123", // WebhookCall ID
+ "type": "email.delivered", // Event type
+ "version": "2024-11-01", // API version
+ "createdAt": "2024-01-15T10:30:00Z",
+ "teamId": 123,
+ "data": { // Original event data nested here
+ "id": "email_123",
+ "status": "DELIVERED",
+ "from": "sender@example.com",
+ "to": ["recipient@example.com"],
+ "occurredAt": "2024-01-15T10:30:00Z"
+ },
+ "attempt": 1
+}
+```
+
+### Step 5: Request Signing
+
+Each request includes security headers for verification:
+
+```
+Content-Type: application/json
+User-Agent: UseSend-Webhook/1.0
+X-UseSend-Event: email.delivered
+X-UseSend-Call: call_abc123
+X-UseSend-Timestamp: 1705312200000
+X-UseSend-Signature: v1=
+X-UseSend-Retry: false
+```
+
+Signature computation:
+
+```typescript
+const signature = HMAC - SHA256(secret, `${timestamp}.${JSON.stringify(body)}`);
+// Format: "v1=" + hex(signature)
+```
+
+## Retry & Failure Handling
+
+### Retry Configuration
+
+```typescript
+const WEBHOOK_MAX_ATTEMPTS = 6;
+const WEBHOOK_BASE_BACKOFF_MS = 5_000; // 5 seconds
+const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30;
+```
+
+### Backoff Schedule (approximate)
+
+| Attempt | Delay (base) | With Jitter |
+| ------- | ------------ | ----------- |
+| 1 | 5s | 5-6.5s |
+| 2 | 10s | 10-13s |
+| 3 | 20s | 20-26s |
+| 4 | 40s | 40-52s |
+| 5 | 80s | 80-104s |
+| 6 | 160s | 160-208s |
+
+### Auto-Disable
+
+After 30 consecutive failures across any calls, the webhook is automatically set to `AUTO_DISABLED` status. This prevents continued delivery attempts to consistently failing endpoints.
+
+### Call Status Flow
+
+```
+PENDING → IN_PROGRESS → DELIVERED (success)
+ → PENDING (retry on failure, attempts < 6)
+ → FAILED (max attempts reached)
+ → DISCARDED (webhook disabled/paused)
+```
+
+## SDK Webhook Verification
+
+Located in `packages/sdk/src/webhooks.ts`:
+
+```typescript
+import { UseSend } from "@usesend/sdk";
+
+const usesend = new UseSend("us_api_key");
+const webhooks = usesend.webhooks("whsec_your_secret");
+
+// Option 1: Verify only (returns boolean)
+const isValid = webhooks.verify(rawBody, { headers: request.headers });
+
+// Option 2: Verify and parse (throws on invalid)
+const event = webhooks.constructEvent(rawBody, { headers: request.headers });
+
+if (event.type === "email.delivered") {
+ console.log(event.data.to); // Type-safe access
+}
+```
+
+## UI Payload Display
+
+The webhook call details UI (`apps/web/src/app/(dashboard)/webhooks/[webhookId]/webhook-call-details.tsx`) reconstructs the full payload for display, matching what was actually sent to the endpoint. This uses the same structure as `buildPayload()` in the service layer.
+
+## Important Files
+
+| File | Purpose |
+| ------------------------------------------------ | --------------------------- |
+| `apps/web/prisma/schema.prisma` | Database models |
+| `apps/web/src/server/service/webhook-service.ts` | Core service & queue worker |
+| `apps/web/src/server/api/routers/webhook.ts` | TRPC API routes |
+| `apps/web/src/lib/constants/plans.ts` | Webhook limits per plan |
+| `packages/lib/src/webhook/webhook-events.ts` | Event type definitions |
+| `packages/sdk/src/webhooks.ts` | SDK verification utilities |
+| `apps/web/src/app/(dashboard)/webhooks/` | UI components |
+
+## Configuration Constants
+
+```typescript
+// apps/web/src/server/service/webhook-service.ts
+const WEBHOOK_DISPATCH_CONCURRENCY = 25; // Parallel workers
+const WEBHOOK_MAX_ATTEMPTS = 6; // Max delivery attempts
+const WEBHOOK_BASE_BACKOFF_MS = 5_000; // Initial retry delay
+const WEBHOOK_LOCK_TTL_MS = 15_000; // Redis lock TTL
+const WEBHOOK_LOCK_RETRY_DELAY_MS = 2_000; // Lock retry delay
+const WEBHOOK_AUTO_DISABLE_THRESHOLD = 30; // Failures before disable
+const WEBHOOK_REQUEST_TIMEOUT_MS = 10_000; // HTTP timeout
+const WEBHOOK_RESPONSE_TEXT_LIMIT = 4_096; // Max response body stored
+const WEBHOOK_EVENT_VERSION = "2024-11-01"; // Default API version
+```
+
+## Plan Limits
+
+```typescript
+// apps/web/src/lib/constants/plans.ts
+FREE: {
+ webhooks: 1;
+}
+BASIC: {
+ webhooks: -1;
+} // unlimited
+```