@@ -115,10 +114,10 @@ export default function WebhookListItem(props: {
defaultChecked={webhook.active}
data-testid="webhook-switch"
disabled={!props.permissions.canEditWebhook}
- onCheckedChange={() =>
+ onCheckedChange={(checked) =>
toggleWebhook.mutate({
id: webhook.id,
- active: !webhook.active,
+ active: checked,
payloadTemplate: webhook.payloadTemplate,
eventTypeId: webhook.eventTypeId || undefined,
})
diff --git a/packages/features/webhooks/lib/WebhookService.test.ts b/packages/features/webhooks/lib/WebhookService.test.ts
index 44f0f3b3c0..961a189a2c 100644
--- a/packages/features/webhooks/lib/WebhookService.test.ts
+++ b/packages/features/webhooks/lib/WebhookService.test.ts
@@ -2,9 +2,10 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+import type { WebhookSubscriber } from "./dto/types";
import { WebhookService } from "./WebhookService";
-import type { GetWebhooksReturnType } from "./getWebhooks";
import getWebhooks from "./getWebhooks";
+import { WebhookVersion as WebhookVersionEnum } from "./interface/IWebhookRepository";
vi.mock("./getWebhooks", () => ({
default: vi.fn(),
@@ -41,7 +42,7 @@ describe("WebhookService", () => {
});
it("should initialize with options and webhooks", async () => {
- const mockWebhooks: GetWebhooksReturnType = [
+ const mockWebhooks: WebhookSubscriber[] = [
{
id: "webhookId",
subscriberUrl: "url",
@@ -51,6 +52,7 @@ describe("WebhookService", () => {
eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
timeUnit: "MINUTE",
time: 5,
+ version: WebhookVersionEnum.V_2021_10_20,
},
];
vi.mocked(getWebhooks).mockResolvedValue(mockWebhooks);
diff --git a/packages/features/webhooks/lib/WebhookService.ts b/packages/features/webhooks/lib/WebhookService.ts
index e8024c9d25..24d98fa84f 100644
--- a/packages/features/webhooks/lib/WebhookService.ts
+++ b/packages/features/webhooks/lib/WebhookService.ts
@@ -1,15 +1,16 @@
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
+import type { WebhookSubscriber } from "./dto/types";
import getWebhooks from "./getWebhooks";
-import type { GetSubscriberOptions, GetWebhooksReturnType } from "./getWebhooks";
+import type { GetSubscriberOptions } from "./getWebhooks";
import sendOrSchedulePayload from "./sendOrSchedulePayload";
const log = logger.getSubLogger({ prefix: ["[WebhookService] "] });
/** This is a WIP. With minimal methods until the API matures and stabilizes */
export class WebhookService {
- private constructor(private options: GetSubscriberOptions, private webhooks: GetWebhooksReturnType) {}
+ private constructor(private options: GetSubscriberOptions, private webhooks: WebhookSubscriber[]) {}
static async init(options: GetSubscriberOptions) {
const webhooks = await getWebhooks(options);
return new WebhookService(options, webhooks);
diff --git a/packages/features/webhooks/lib/constants.ts b/packages/features/webhooks/lib/constants.ts
index e8b98e6e34..ec3339c80c 100644
--- a/packages/features/webhooks/lib/constants.ts
+++ b/packages/features/webhooks/lib/constants.ts
@@ -1,7 +1,33 @@
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+import { WebhookVersion } from "./interface/IWebhookRepository";
+
// this is exported as we can't use `WebhookTriggerEvents` in the frontend straight-off
+/**
+ * Version label map - transforms version enum values to display labels.
+ * Since our version values ARE date strings (e.g., "2021-10-20"), labels match values.
+ * This map allows for custom labels if needed in the future.
+ */
+export const WEBHOOK_VERSION_LABELS: Record
= {
+ [WebhookVersion.V_2021_10_20]: "2021-10-20",
+ // Add new versions here: [WebhookVersion.V_YYYY_MM_DD]: "YYYY-MM-DD",
+};
+
+/**
+ * Pre-built options for Select components - automatically generated from all versions
+ */
+export const WEBHOOK_VERSION_OPTIONS = Object.values(WebhookVersion).map((version) => ({
+ value: version,
+ label: WEBHOOK_VERSION_LABELS[version] ?? version,
+}));
+
+/**
+ * Get display label for a version
+ */
+export const getWebhookVersionLabel = (version: WebhookVersion): string =>
+ WEBHOOK_VERSION_LABELS[version] ?? version;
+
export const WEBHOOK_TRIGGER_EVENTS_GROUPED_BY_APP = {
core: [
WebhookTriggerEvents.BOOKING_CANCELLED,
diff --git a/packages/features/webhooks/lib/dto/types.ts b/packages/features/webhooks/lib/dto/types.ts
index d6dd0a5f2f..89db2bcd1b 100644
--- a/packages/features/webhooks/lib/dto/types.ts
+++ b/packages/features/webhooks/lib/dto/types.ts
@@ -1,6 +1,8 @@
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { WebhookVersion } from "../interface/IWebhookRepository";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
export interface BaseEventDTO {
@@ -402,6 +404,33 @@ export interface WebhookTriggerArgs {
paymentData?: Record;
}
+/**
+ * Full webhook entity - used for CRUD operations and UI display
+ * Contains all fields needed for webhook management
+ */
+export interface Webhook {
+ id: string;
+ subscriberUrl: string;
+ payloadTemplate: string | null;
+ appId: string | null;
+ secret: string | null;
+ active: boolean;
+ eventTriggers: WebhookTriggerEvents[];
+ eventTypeId: number | null;
+ teamId: number | null;
+ userId: number | null;
+ time: number | null;
+ timeUnit: TimeUnit | null;
+ version: WebhookVersion;
+ createdAt: Date;
+ platform: boolean;
+ platformOAuthClientId: string | null;
+}
+
+/**
+ * Webhook subscriber - minimal type for webhook delivery/payload sending
+ * Subset of Webhook with only fields needed for triggering webhooks
+ */
export interface WebhookSubscriber {
id: string;
subscriberUrl: string;
@@ -411,6 +440,7 @@ export interface WebhookSubscriber {
time?: number | null;
timeUnit?: TimeUnit | null;
eventTriggers: WebhookTriggerEvents[];
+ version: WebhookVersion;
}
export interface WebhookDeliveryResult {
@@ -422,6 +452,35 @@ export interface WebhookDeliveryResult {
webhookId: string;
}
+/**
+ * Payment data associated with webhook events
+ * Used when sending booking payment webhooks
+ */
+export interface PaymentData {
+ id: number;
+ fee: number;
+ currency: string;
+ success: boolean;
+ refunded: boolean;
+ externalId: string;
+ data: unknown;
+ appId: string | null;
+ bookingId: number;
+}
+
+/**
+ * Minimal webhook shape for URL reservation check
+ * Used to check if a subscriber URL is already in use within a scope
+ */
+export interface WebhookForReservationCheck {
+ id: string;
+ subscriberUrl: string;
+ teamId: number | null;
+ userId: number | null;
+ eventTypeId: number | null;
+ platform: boolean;
+}
+
// Legacy types moved from sendPayload.ts for backward compatibility
export type EventTypeInfo = {
eventTitle?: string | null;
@@ -536,3 +595,20 @@ export type BookingWebhookEventDTO =
| BookingPaymentInitiatedDTO
| BookingRejectedDTO
| BookingNoShowDTO;
+
+/**
+ * Grouped webhooks for UI display with profile and permission metadata
+ */
+export interface WebhookGroup {
+ teamId?: number | null;
+ profile: {
+ slug: string | null;
+ name: string | null;
+ image?: string;
+ };
+ metadata?: {
+ canModify: boolean;
+ canDelete: boolean;
+ };
+ webhooks: Webhook[]; // Full webhook entities for UI display
+}
diff --git a/packages/features/webhooks/lib/factory/BookingPayloadBuilder.ts b/packages/features/webhooks/lib/factory/BookingPayloadBuilder.ts
deleted file mode 100644
index 0c09366997..0000000000
--- a/packages/features/webhooks/lib/factory/BookingPayloadBuilder.ts
+++ /dev/null
@@ -1,209 +0,0 @@
-import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
-import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
-import type { CalendarEvent } from "@calcom/types/Calendar";
-
-import type { EventTypeInfo, BookingWebhookEventDTO } from "../dto/types";
-import type { WebhookPayload } from "./types";
-
-type BookingExtraDataMap = {
- [WebhookTriggerEvents.BOOKING_CREATED]: null;
- [WebhookTriggerEvents.BOOKING_CANCELLED]: { cancelledBy?: string; cancellationReason?: string };
- [WebhookTriggerEvents.BOOKING_REQUESTED]: null;
- [WebhookTriggerEvents.BOOKING_REJECTED]: null;
- [WebhookTriggerEvents.BOOKING_RESCHEDULED]: {
- rescheduleId?: number;
- rescheduleUid?: string;
- rescheduleStartTime?: string;
- rescheduleEndTime?: string;
- rescheduledBy?: string;
- };
- [WebhookTriggerEvents.BOOKING_PAID]: { paymentId?: number; paymentData?: Record };
- [WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED]: {
- paymentId?: number;
- paymentData?: Record;
- };
-};
-
-interface CreateBookingWebhookPayloadParams {
- booking: {
- id: number;
- eventTypeId: number | null;
- userId: number | null;
- smsReminderNumber?: string | null;
- };
- eventType: EventTypeInfo;
- evt: CalendarEvent;
- status: BookingStatus;
- triggerEvent: WebhookTriggerEvents;
- createdAt: string;
- extra?: BookingExtraDataMap[T];
-}
-
-function createBookingWebhookPayload(
- params: CreateBookingWebhookPayloadParams
-): WebhookPayload {
- const utcOffsetOrganizer = getUTCOffsetByTimezone(params.evt.organizer?.timeZone, params.evt.startTime);
- const organizer = { ...params.evt.organizer, utcOffset: utcOffsetOrganizer };
-
- return {
- triggerEvent: params.triggerEvent,
- createdAt: params.createdAt,
- payload: {
- ...params.evt,
- bookingId: params.booking.id,
- startTime: params.evt.startTime,
- endTime: params.evt.endTime,
- title: params.evt.title,
- type: params.evt.type,
- organizer,
- attendees:
- params.evt.attendees?.map((a) => ({
- ...a,
- utcOffset: getUTCOffsetByTimezone(a.timeZone, params.evt.startTime),
- })) ?? [],
- location: params.evt.location,
- uid: params.evt.uid,
- customInputs: params.evt.customInputs,
- responses: params.evt.responses,
- userFieldsResponses: params.evt.userFieldsResponses,
- status: params.status,
- eventTitle: params.eventType?.eventTitle,
- eventDescription: params.eventType?.eventDescription,
- requiresConfirmation: params.eventType?.requiresConfirmation,
- price: params.eventType?.price,
- currency: params.eventType?.currency,
- length: params.eventType?.length,
- smsReminderNumber: params.booking.smsReminderNumber || undefined,
- description: params.evt.description || params.evt.additionalNotes,
- ...(params.extra || {}),
- },
- };
-}
-
-const BOOKING_WEBHOOK_EVENTS: WebhookTriggerEvents[] = [
- WebhookTriggerEvents.BOOKING_CREATED,
- WebhookTriggerEvents.BOOKING_CANCELLED,
- WebhookTriggerEvents.BOOKING_REQUESTED,
- WebhookTriggerEvents.BOOKING_RESCHEDULED,
- WebhookTriggerEvents.BOOKING_REJECTED,
- WebhookTriggerEvents.BOOKING_PAID,
- WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
- WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
-];
-
-export class BookingPayloadBuilder {
- canHandle(triggerEvent: WebhookTriggerEvents): boolean {
- return BOOKING_WEBHOOK_EVENTS.includes(triggerEvent);
- }
-
- build(dto: BookingWebhookEventDTO): WebhookPayload {
- switch (dto.triggerEvent) {
- case WebhookTriggerEvents.BOOKING_CREATED:
- return createBookingWebhookPayload({
- booking: dto.booking,
- eventType: dto.eventType,
- evt: dto.evt,
- status: BookingStatus.ACCEPTED,
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- });
-
- case WebhookTriggerEvents.BOOKING_CANCELLED:
- return createBookingWebhookPayload({
- booking: dto.booking,
- eventType: dto.eventType,
- evt: dto.evt,
- status: BookingStatus.CANCELLED,
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- extra: {
- cancelledBy: dto.cancelledBy,
- cancellationReason: dto.cancellationReason,
- },
- });
-
- case WebhookTriggerEvents.BOOKING_REQUESTED:
- return createBookingWebhookPayload({
- booking: dto.booking,
- eventType: dto.eventType,
- evt: dto.evt,
- status: BookingStatus.PENDING,
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- });
-
- case WebhookTriggerEvents.BOOKING_REJECTED:
- return createBookingWebhookPayload({
- booking: dto.booking,
- eventType: dto.eventType,
- evt: dto.evt,
- status: BookingStatus.REJECTED,
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- });
-
- case WebhookTriggerEvents.BOOKING_RESCHEDULED:
- return createBookingWebhookPayload({
- booking: dto.booking,
- eventType: dto.eventType,
- evt: dto.evt,
- status: BookingStatus.ACCEPTED,
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- extra: {
- rescheduleId: dto.rescheduleId,
- rescheduleUid: dto.rescheduleUid,
- rescheduleStartTime: dto.rescheduleStartTime,
- rescheduleEndTime: dto.rescheduleEndTime,
- rescheduledBy: dto.rescheduledBy,
- },
- });
-
- case WebhookTriggerEvents.BOOKING_PAID:
- return createBookingWebhookPayload({
- booking: dto.booking,
- eventType: dto.eventType,
- evt: dto.evt,
- status: BookingStatus.ACCEPTED,
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- extra: {
- paymentId: dto.paymentId,
- paymentData: dto.paymentData,
- },
- });
-
- case WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED:
- return createBookingWebhookPayload({
- booking: dto.booking,
- eventType: dto.eventType,
- evt: dto.evt,
- status: BookingStatus.ACCEPTED,
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- extra: {
- paymentId: dto.paymentId,
- paymentData: dto.paymentData,
- },
- });
-
- case WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED:
- return {
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- payload: {
- bookingUid: dto.bookingUid,
- bookingId: dto.bookingId,
- attendees: dto.attendees,
- message: dto.message,
- },
- };
-
- default: {
- // TypeScript exhaustiveness check - this should never happen if all cases are covered
- const _exhaustiveCheck: never = dto;
- throw new Error(`Unsupported booking trigger: ${JSON.stringify(_exhaustiveCheck)}`);
- }
- }
- }
-}
diff --git a/packages/features/webhooks/lib/factory/InstantMeetingBuilder.ts b/packages/features/webhooks/lib/factory/InstantMeetingBuilder.ts
deleted file mode 100644
index 23d46062f2..0000000000
--- a/packages/features/webhooks/lib/factory/InstantMeetingBuilder.ts
+++ /dev/null
@@ -1,26 +0,0 @@
-import { WebhookTriggerEvents } from "@calcom/prisma/enums";
-
-import type { InstantMeetingDTO } from "../dto/types";
-import type { WebhookPayload } from "./types";
-
-export class InstantMeetingBuilder {
- canHandle(triggerEvent: WebhookTriggerEvents): boolean {
- return triggerEvent === WebhookTriggerEvents.INSTANT_MEETING;
- }
-
- build(dto: InstantMeetingDTO): WebhookPayload {
- return {
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- payload: {
- title: dto.title,
- body: dto.body,
- icon: dto.icon,
- url: dto.url,
- actions: dto.actions,
- requireInteraction: dto.requireInteraction,
- type: dto.type,
- },
- };
- }
-}
diff --git a/packages/features/webhooks/lib/factory/MeetingPayloadBuilder.ts b/packages/features/webhooks/lib/factory/MeetingPayloadBuilder.ts
deleted file mode 100644
index 4a63747df4..0000000000
--- a/packages/features/webhooks/lib/factory/MeetingPayloadBuilder.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { WebhookTriggerEvents } from "@calcom/prisma/enums";
-
-import type { MeetingStartedDTO, MeetingEndedDTO } from "../dto/types";
-import type { WebhookPayload } from "./types";
-
-export class MeetingPayloadBuilder {
- canHandle(triggerEvent: WebhookTriggerEvents): boolean {
- return (
- triggerEvent === WebhookTriggerEvents.MEETING_STARTED ||
- triggerEvent === WebhookTriggerEvents.MEETING_ENDED
- );
- }
-
- build(dto: MeetingStartedDTO | MeetingEndedDTO): WebhookPayload {
- return {
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- payload: { ...dto.booking },
- };
- }
-}
diff --git a/packages/features/webhooks/lib/factory/OOOPayloadBuilder.ts b/packages/features/webhooks/lib/factory/OOOPayloadBuilder.ts
deleted file mode 100644
index 685200c903..0000000000
--- a/packages/features/webhooks/lib/factory/OOOPayloadBuilder.ts
+++ /dev/null
@@ -1,18 +0,0 @@
-import { WebhookTriggerEvents } from "@calcom/prisma/enums";
-
-import type { OOOCreatedDTO } from "../dto/types";
-import type { WebhookPayload } from "./types";
-
-export class OOOPayloadBuilder {
- canHandle(triggerEvent: WebhookTriggerEvents): boolean {
- return triggerEvent === WebhookTriggerEvents.OOO_CREATED;
- }
-
- build(dto: OOOCreatedDTO): WebhookPayload {
- return {
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- payload: { oooEntry: dto.oooEntry },
- };
- }
-}
diff --git a/packages/features/webhooks/lib/factory/RecordingPayloadBuilder.ts b/packages/features/webhooks/lib/factory/RecordingPayloadBuilder.ts
deleted file mode 100644
index 207bf798b5..0000000000
--- a/packages/features/webhooks/lib/factory/RecordingPayloadBuilder.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { WebhookTriggerEvents } from "@calcom/prisma/enums";
-
-import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../dto/types";
-import type { WebhookPayload } from "./types";
-
-export class RecordingPayloadBuilder {
- canHandle(triggerEvent: WebhookTriggerEvents): boolean {
- return (
- triggerEvent === WebhookTriggerEvents.RECORDING_READY ||
- triggerEvent === WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED
- );
- }
-
- build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload {
- if (dto.triggerEvent === WebhookTriggerEvents.RECORDING_READY) {
- return {
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- payload: { downloadLink: dto.downloadLink },
- };
- }
-
- return {
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- payload: {
- downloadLinks: dto.downloadLinks,
- },
- };
- }
-}
diff --git a/packages/features/webhooks/lib/factory/base/BaseBookingPayloadBuilder.test.ts b/packages/features/webhooks/lib/factory/base/BaseBookingPayloadBuilder.test.ts
new file mode 100644
index 0000000000..bf2fd22770
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseBookingPayloadBuilder.test.ts
@@ -0,0 +1,213 @@
+import { describe, it, expect, vi } from "vitest";
+import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+
+import type { BookingWebhookEventDTO, EventTypeInfo } from "../../dto/types";
+import { BookingPayloadBuilder } from "../versioned/v2021-10-20/BookingPayloadBuilder";
+
+vi.mock("@calcom/lib/dayjs", () => ({
+ getUTCOffsetByTimezone: vi.fn(() => 0),
+}));
+
+describe("BookingPayloadBuilder (v2021-10-20)", () => {
+ const mockEventType: EventTypeInfo = {
+ eventTitle: "Test Event",
+ eventDescription: "Test Description",
+ requiresConfirmation: false,
+ price: 0,
+ currency: "USD",
+ length: 30,
+ };
+
+ const mockCalendarEvent: CalendarEvent = {
+ type: "test-event",
+ title: "Test Meeting",
+ description: "Meeting description",
+ additionalNotes: "Additional notes",
+ startTime: "2024-01-15T10:00:00Z",
+ endTime: "2024-01-15T10:30:00Z",
+ organizer: {
+ id: 1,
+ email: "organizer@test.com",
+ name: "Test Organizer",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ attendees: [
+ {
+ email: "attendee@test.com",
+ name: "Test Attendee",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ ],
+ location: "https://cal.com/video/123",
+ uid: "booking-uid-123",
+ customInputs: {},
+ responses: {},
+ userFieldsResponses: {},
+ };
+
+ const createMockDTO = (
+ triggerEvent: WebhookTriggerEvents,
+ extra: Partial = {}
+ ): BookingWebhookEventDTO => ({
+ triggerEvent,
+ createdAt: "2024-01-15T10:00:00Z",
+ booking: {
+ id: 1,
+ eventTypeId: 1,
+ userId: 1,
+ smsReminderNumber: null,
+ },
+ eventType: mockEventType,
+ evt: mockCalendarEvent,
+ ...extra,
+ });
+
+ const builder = new BookingPayloadBuilder();
+
+ describe("BOOKING_CREATED", () => {
+ it("should build payload with ACCEPTED status", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CREATED);
+ expect(payload.payload.status).toBe(BookingStatus.ACCEPTED);
+ expect(payload.payload.bookingId).toBe(1);
+ expect(payload.payload.title).toBe("Test Meeting");
+ expect(payload.payload.organizer.email).toBe("organizer@test.com");
+ expect(payload.payload.attendees).toHaveLength(1);
+ });
+
+ it("should include eventType fields", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
+ const payload = builder.build(dto);
+
+ expect(payload.payload.eventTitle).toBe("Test Event");
+ expect(payload.payload.eventDescription).toBe("Test Description");
+ expect(payload.payload.price).toBe(0);
+ expect(payload.payload.currency).toBe("USD");
+ expect(payload.payload.length).toBe(30);
+ });
+ });
+
+ describe("BOOKING_CANCELLED", () => {
+ it("should build payload with CANCELLED status and extra fields", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CANCELLED, {
+ cancelledBy: "user@test.com",
+ cancellationReason: "Schedule conflict",
+ });
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CANCELLED);
+ expect(payload.payload.status).toBe(BookingStatus.CANCELLED);
+ expect(payload.payload.cancelledBy).toBe("user@test.com");
+ expect(payload.payload.cancellationReason).toBe("Schedule conflict");
+ });
+ });
+
+ describe("BOOKING_REQUESTED", () => {
+ it("should build payload with PENDING status", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REQUESTED);
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_REQUESTED);
+ expect(payload.payload.status).toBe(BookingStatus.PENDING);
+ });
+ });
+
+ describe("BOOKING_REJECTED", () => {
+ it("should build payload with REJECTED status", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_REJECTED);
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_REJECTED);
+ expect(payload.payload.status).toBe(BookingStatus.REJECTED);
+ });
+ });
+
+ describe("BOOKING_RESCHEDULED", () => {
+ it("should build payload with reschedule extra fields", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_RESCHEDULED, {
+ rescheduleId: 2,
+ rescheduleUid: "reschedule-uid-456",
+ rescheduleStartTime: "2024-01-16T10:00:00Z",
+ rescheduleEndTime: "2024-01-16T10:30:00Z",
+ rescheduledBy: "user@test.com",
+ });
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_RESCHEDULED);
+ expect(payload.payload.status).toBe(BookingStatus.ACCEPTED);
+ expect(payload.payload.rescheduleId).toBe(2);
+ expect(payload.payload.rescheduleUid).toBe("reschedule-uid-456");
+ expect(payload.payload.rescheduledBy).toBe("user@test.com");
+ });
+ });
+
+ describe("BOOKING_PAID", () => {
+ it("should build payload with payment extra fields", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_PAID, {
+ paymentId: 123,
+ paymentData: { stripeId: "stripe_123" },
+ });
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_PAID);
+ expect(payload.payload.paymentId).toBe(123);
+ expect(payload.payload.paymentData).toEqual({ stripeId: "stripe_123" });
+ });
+ });
+
+ describe("BOOKING_PAYMENT_INITIATED", () => {
+ it("should build payload with payment extra fields", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED, {
+ paymentId: 456,
+ paymentData: { status: "pending" },
+ });
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED);
+ expect(payload.payload.paymentId).toBe(456);
+ });
+ });
+
+ describe("BOOKING_NO_SHOW_UPDATED", () => {
+ it("should build no-show specific payload", () => {
+ const dto: BookingWebhookEventDTO = {
+ triggerEvent: WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
+ createdAt: "2024-01-15T10:00:00Z",
+ bookingUid: "booking-uid-123",
+ bookingId: 1,
+ attendees: [{ email: "attendee@test.com", noShow: true }],
+ message: "Attendee marked as no-show",
+ } as BookingWebhookEventDTO;
+
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED);
+ expect(payload.payload.bookingUid).toBe("booking-uid-123");
+ expect(payload.payload.bookingId).toBe(1);
+ expect(payload.payload.message).toBe("Attendee marked as no-show");
+ });
+ });
+
+ describe("attendee UTC offset", () => {
+ it("should add utcOffset to each attendee", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
+ const payload = builder.build(dto);
+
+ expect(payload.payload.attendees[0]).toHaveProperty("utcOffset");
+ });
+ });
+
+ describe("organizer UTC offset", () => {
+ it("should add utcOffset to organizer", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
+ const payload = builder.build(dto);
+
+ expect(payload.payload.organizer).toHaveProperty("utcOffset");
+ });
+ });
+});
diff --git a/packages/features/webhooks/lib/factory/base/BaseBookingPayloadBuilder.ts b/packages/features/webhooks/lib/factory/base/BaseBookingPayloadBuilder.ts
new file mode 100644
index 0000000000..c92727d42c
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseBookingPayloadBuilder.ts
@@ -0,0 +1,61 @@
+import type { BookingStatus } from "@calcom/prisma/enums";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+
+import type { EventTypeInfo, BookingWebhookEventDTO } from "../../dto/types";
+import type { WebhookPayload } from "../types";
+import type { IBookingPayloadBuilder } from "../versioned/PayloadBuilderFactory";
+
+/**
+ * Extra data shape per booking trigger event
+ */
+export type BookingExtraDataMap = {
+ [WebhookTriggerEvents.BOOKING_CREATED]: null;
+ [WebhookTriggerEvents.BOOKING_CANCELLED]: { cancelledBy?: string; cancellationReason?: string };
+ [WebhookTriggerEvents.BOOKING_REQUESTED]: null;
+ [WebhookTriggerEvents.BOOKING_REJECTED]: null;
+ [WebhookTriggerEvents.BOOKING_RESCHEDULED]: {
+ rescheduleId?: number;
+ rescheduleUid?: string;
+ rescheduleStartTime?: string;
+ rescheduleEndTime?: string;
+ rescheduledBy?: string;
+ };
+ [WebhookTriggerEvents.BOOKING_PAID]: { paymentId?: number; paymentData?: Record };
+ [WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED]: {
+ paymentId?: number;
+ paymentData?: Record;
+ };
+};
+
+export interface BookingPayloadParams {
+ booking: {
+ id: number;
+ eventTypeId: number | null;
+ userId: number | null;
+ smsReminderNumber?: string | null;
+ };
+ eventType: EventTypeInfo;
+ evt: CalendarEvent;
+ status: BookingStatus;
+ triggerEvent: WebhookTriggerEvents;
+ createdAt: string;
+ extra?: BookingExtraDataMap[T];
+}
+
+/**
+ * Abstract base class for booking payload builders.
+ *
+ * This class defines the interface that all version-specific booking payload
+ * builders must implement. It does NOT contain any version-specific payload logic.
+ *
+ * Each webhook version should have its own concrete implementation in
+ * versioned/v{VERSION}/BookingPayloadBuilder.ts
+ */
+export abstract class BaseBookingPayloadBuilder implements IBookingPayloadBuilder {
+ /**
+ * Build the booking webhook payload.
+ * Each version must implement this method with its specific payload structure.
+ */
+ abstract build(dto: BookingWebhookEventDTO): WebhookPayload;
+}
diff --git a/packages/features/webhooks/lib/factory/base/BaseDelegationPayloadBuilder.ts b/packages/features/webhooks/lib/factory/base/BaseDelegationPayloadBuilder.ts
new file mode 100644
index 0000000000..8476b0a22d
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseDelegationPayloadBuilder.ts
@@ -0,0 +1,21 @@
+import type { DelegationCredentialErrorDTO } from "../../dto/types";
+import type { WebhookPayload } from "../types";
+import type { IDelegationPayloadBuilder } from "../versioned/PayloadBuilderFactory";
+
+/**
+ * Abstract base class for delegation payload builders.
+ *
+ * This class defines the interface that all version-specific delegation payload
+ * builders must implement. It does NOT contain any version-specific payload logic.
+ *
+ * Each webhook version should have its own concrete implementation in
+ * versioned/v{VERSION}/DelegationPayloadBuilder.ts
+ */
+export abstract class BaseDelegationPayloadBuilder implements IDelegationPayloadBuilder {
+ /**
+ * Build the delegation webhook payload.
+ * Each version must implement this method with its specific payload structure.
+ */
+ abstract build(dto: DelegationCredentialErrorDTO): WebhookPayload;
+}
+
diff --git a/packages/features/webhooks/lib/factory/base/BaseFormPayloadBuilder.test.ts b/packages/features/webhooks/lib/factory/base/BaseFormPayloadBuilder.test.ts
new file mode 100644
index 0000000000..62fe918255
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseFormPayloadBuilder.test.ts
@@ -0,0 +1,83 @@
+import { describe, it, expect } from "vitest";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../../dto/types";
+import { FormPayloadBuilder } from "../versioned/v2021-10-20/FormPayloadBuilder";
+
+describe("FormPayloadBuilder (v2021-10-20)", () => {
+ const builder = new FormPayloadBuilder();
+
+ const createMockDTO = (
+ triggerEvent: typeof WebhookTriggerEvents.FORM_SUBMITTED | typeof WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT
+ ): FormSubmittedDTO | FormSubmittedNoEventDTO =>
+ ({
+ triggerEvent,
+ createdAt: "2024-01-15T10:00:00Z",
+ form: {
+ id: "form-123",
+ name: "Test Form",
+ },
+ teamId: 1,
+ response: {
+ data: {
+ name: { value: "John Doe", response: "John Doe" },
+ email: { value: "john@test.com", response: "john@test.com" },
+ },
+ },
+ }) as FormSubmittedDTO | FormSubmittedNoEventDTO;
+
+ describe("FORM_SUBMITTED", () => {
+ it("should build payload with form details", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED);
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.FORM_SUBMITTED);
+ expect(payload.payload.formId).toBe("form-123");
+ expect(payload.payload.formName).toBe("Test Form");
+ expect(payload.payload.teamId).toBe(1);
+ });
+
+ it("should include responses", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED);
+ const payload = builder.build(dto);
+
+ expect(payload.payload.responses).toBeDefined();
+ expect(payload.payload.responses.name).toEqual({
+ value: "John Doe",
+ response: "John Doe",
+ });
+ });
+
+ it("should add backwards compatibility fields at root level", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED);
+ const payload = builder.build(dto);
+
+ // Field values should be at root level for backwards compatibility
+ expect(payload.payload.name).toBe("John Doe");
+ expect(payload.payload.email).toBe("john@test.com");
+ });
+ });
+
+ describe("FORM_SUBMITTED_NO_EVENT", () => {
+ it("should build payload for forms without events", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT);
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT);
+ expect(payload.payload.formId).toBe("form-123");
+ });
+ });
+
+ describe("null teamId", () => {
+ it("should handle null teamId", () => {
+ const dto = {
+ ...createMockDTO(WebhookTriggerEvents.FORM_SUBMITTED),
+ teamId: undefined,
+ } as FormSubmittedDTO;
+
+ const payload = builder.build(dto);
+
+ expect(payload.payload.teamId).toBeNull();
+ });
+ });
+});
diff --git a/packages/features/webhooks/lib/factory/base/BaseFormPayloadBuilder.ts b/packages/features/webhooks/lib/factory/base/BaseFormPayloadBuilder.ts
new file mode 100644
index 0000000000..67be54454b
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseFormPayloadBuilder.ts
@@ -0,0 +1,20 @@
+import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../../dto/types";
+import type { WebhookPayload } from "../types";
+import type { IFormPayloadBuilder } from "../versioned/PayloadBuilderFactory";
+
+/**
+ * Abstract base class for form payload builders.
+ *
+ * This class defines the interface that all version-specific form payload
+ * builders must implement. It does NOT contain any version-specific payload logic.
+ *
+ * Each webhook version should have its own concrete implementation in
+ * versioned/v{VERSION}/FormPayloadBuilder.ts
+ */
+export abstract class BaseFormPayloadBuilder implements IFormPayloadBuilder {
+ /**
+ * Build the form webhook payload.
+ * Each version must implement this method with its specific payload structure.
+ */
+ abstract build(dto: FormSubmittedDTO | FormSubmittedNoEventDTO): WebhookPayload;
+}
diff --git a/packages/features/webhooks/lib/factory/base/BaseInstantMeetingBuilder.test.ts b/packages/features/webhooks/lib/factory/base/BaseInstantMeetingBuilder.test.ts
new file mode 100644
index 0000000000..e82b94041a
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseInstantMeetingBuilder.test.ts
@@ -0,0 +1,40 @@
+import { describe, it, expect } from "vitest";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { InstantMeetingDTO } from "../../dto/types";
+import { InstantMeetingBuilder } from "../versioned/v2021-10-20/InstantMeetingBuilder";
+
+describe("InstantMeetingBuilder (v2021-10-20)", () => {
+ const builder = new InstantMeetingBuilder();
+
+ describe("INSTANT_MEETING", () => {
+ it("should build payload with all instant meeting fields", () => {
+ const dto: InstantMeetingDTO = {
+ triggerEvent: WebhookTriggerEvents.INSTANT_MEETING,
+ createdAt: "2024-01-15T10:00:00Z",
+ title: "Instant Meeting Request",
+ body: "Someone is requesting an instant meeting",
+ icon: "https://cal.com/icon.png",
+ url: "https://cal.com/meeting/instant-123",
+ actions: [
+ { action: "accept", title: "Accept" },
+ { action: "decline", title: "Decline" },
+ ],
+ requireInteraction: true,
+ type: "instant",
+ };
+
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.INSTANT_MEETING);
+ expect(payload.createdAt).toBe("2024-01-15T10:00:00Z");
+ expect(payload.payload.title).toBe("Instant Meeting Request");
+ expect(payload.payload.body).toBe("Someone is requesting an instant meeting");
+ expect(payload.payload.icon).toBe("https://cal.com/icon.png");
+ expect(payload.payload.url).toBe("https://cal.com/meeting/instant-123");
+ expect(payload.payload.actions).toHaveLength(2);
+ expect(payload.payload.requireInteraction).toBe(true);
+ expect(payload.payload.type).toBe("instant");
+ });
+ });
+});
diff --git a/packages/features/webhooks/lib/factory/base/BaseInstantMeetingBuilder.ts b/packages/features/webhooks/lib/factory/base/BaseInstantMeetingBuilder.ts
new file mode 100644
index 0000000000..339054137c
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseInstantMeetingBuilder.ts
@@ -0,0 +1,20 @@
+import type { InstantMeetingDTO } from "../../dto/types";
+import type { WebhookPayload } from "../types";
+import type { IInstantMeetingBuilder } from "../versioned/PayloadBuilderFactory";
+
+/**
+ * Abstract base class for instant meeting payload builders.
+ *
+ * This class defines the interface that all version-specific instant meeting
+ * payload builders must implement. It does NOT contain any version-specific payload logic.
+ *
+ * Each webhook version should have its own concrete implementation in
+ * versioned/v{VERSION}/InstantMeetingBuilder.ts
+ */
+export abstract class BaseInstantMeetingBuilder implements IInstantMeetingBuilder {
+ /**
+ * Build the instant meeting webhook payload.
+ * Each version must implement this method with its specific payload structure.
+ */
+ abstract build(dto: InstantMeetingDTO): WebhookPayload;
+}
diff --git a/packages/features/webhooks/lib/factory/base/BaseMeetingPayloadBuilder.test.ts b/packages/features/webhooks/lib/factory/base/BaseMeetingPayloadBuilder.test.ts
new file mode 100644
index 0000000000..95ef8ae023
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseMeetingPayloadBuilder.test.ts
@@ -0,0 +1,51 @@
+import { describe, it, expect } from "vitest";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { MeetingStartedDTO, MeetingEndedDTO } from "../../dto/types";
+import { MeetingPayloadBuilder } from "../versioned/v2021-10-20/MeetingPayloadBuilder";
+
+describe("MeetingPayloadBuilder (v2021-10-20)", () => {
+ const builder = new MeetingPayloadBuilder();
+
+ const mockBookingData = {
+ id: 1,
+ uid: "booking-uid-123",
+ title: "Test Meeting",
+ startTime: "2024-01-15T10:00:00Z",
+ endTime: "2024-01-15T10:30:00Z",
+ };
+
+ describe("MEETING_STARTED", () => {
+ it("should build payload with booking data", () => {
+ const dto: MeetingStartedDTO = {
+ triggerEvent: WebhookTriggerEvents.MEETING_STARTED,
+ createdAt: "2024-01-15T10:00:00Z",
+ booking: mockBookingData,
+ };
+
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.MEETING_STARTED);
+ expect(payload.createdAt).toBe("2024-01-15T10:00:00Z");
+ expect(payload.payload.id).toBe(1);
+ expect(payload.payload.uid).toBe("booking-uid-123");
+ expect(payload.payload.title).toBe("Test Meeting");
+ });
+ });
+
+ describe("MEETING_ENDED", () => {
+ it("should build payload with booking data", () => {
+ const dto: MeetingEndedDTO = {
+ triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
+ createdAt: "2024-01-15T10:30:00Z",
+ booking: mockBookingData,
+ };
+
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.MEETING_ENDED);
+ expect(payload.createdAt).toBe("2024-01-15T10:30:00Z");
+ expect(payload.payload).toEqual(mockBookingData);
+ });
+ });
+});
diff --git a/packages/features/webhooks/lib/factory/base/BaseMeetingPayloadBuilder.ts b/packages/features/webhooks/lib/factory/base/BaseMeetingPayloadBuilder.ts
new file mode 100644
index 0000000000..77520f8af9
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseMeetingPayloadBuilder.ts
@@ -0,0 +1,27 @@
+import type {
+ MeetingStartedDTO,
+ MeetingEndedDTO,
+ AfterHostsNoShowDTO,
+ AfterGuestsNoShowDTO,
+} from "../../dto/types";
+import type { WebhookPayload } from "../types";
+import type { IMeetingPayloadBuilder } from "../versioned/PayloadBuilderFactory";
+
+/**
+ * Abstract base class for meeting payload builders.
+ *
+ * This class defines the interface that all version-specific meeting payload
+ * builders must implement. It does NOT contain any version-specific payload logic.
+ *
+ * Each webhook version should have its own concrete implementation in
+ * versioned/v{VERSION}/MeetingPayloadBuilder.ts
+ */
+export abstract class BaseMeetingPayloadBuilder implements IMeetingPayloadBuilder {
+ /**
+ * Build the meeting webhook payload.
+ * Each version must implement this method with its specific payload structure.
+ */
+ abstract build(
+ dto: MeetingStartedDTO | MeetingEndedDTO | AfterHostsNoShowDTO | AfterGuestsNoShowDTO
+ ): WebhookPayload;
+}
diff --git a/packages/features/webhooks/lib/factory/base/BaseOOOPayloadBuilder.test.ts b/packages/features/webhooks/lib/factory/base/BaseOOOPayloadBuilder.test.ts
new file mode 100644
index 0000000000..1458dc7511
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseOOOPayloadBuilder.test.ts
@@ -0,0 +1,37 @@
+import { describe, it, expect } from "vitest";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { OOOCreatedDTO } from "../../dto/types";
+import { OOOPayloadBuilder } from "../versioned/v2021-10-20/OOOPayloadBuilder";
+
+describe("OOOPayloadBuilder (v2021-10-20)", () => {
+ const builder = new OOOPayloadBuilder();
+
+ describe("OOO_CREATED", () => {
+ it("should build payload with OOO entry", () => {
+ const dto: OOOCreatedDTO = {
+ triggerEvent: WebhookTriggerEvents.OOO_CREATED,
+ createdAt: "2024-01-15T10:00:00Z",
+ oooEntry: {
+ id: 1,
+ userId: 1,
+ start: "2024-01-20T00:00:00Z",
+ end: "2024-01-25T00:00:00Z",
+ reason: "Vacation",
+ },
+ };
+
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.OOO_CREATED);
+ expect(payload.createdAt).toBe("2024-01-15T10:00:00Z");
+ expect(payload.payload.oooEntry).toEqual({
+ id: 1,
+ userId: 1,
+ start: "2024-01-20T00:00:00Z",
+ end: "2024-01-25T00:00:00Z",
+ reason: "Vacation",
+ });
+ });
+ });
+});
diff --git a/packages/features/webhooks/lib/factory/base/BaseOOOPayloadBuilder.ts b/packages/features/webhooks/lib/factory/base/BaseOOOPayloadBuilder.ts
new file mode 100644
index 0000000000..6e35492fef
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseOOOPayloadBuilder.ts
@@ -0,0 +1,20 @@
+import type { OOOCreatedDTO } from "../../dto/types";
+import type { WebhookPayload } from "../types";
+import type { IOOOPayloadBuilder } from "../versioned/PayloadBuilderFactory";
+
+/**
+ * Abstract base class for OOO (Out of Office) payload builders.
+ *
+ * This class defines the interface that all version-specific OOO payload
+ * builders must implement. It does NOT contain any version-specific payload logic.
+ *
+ * Each webhook version should have its own concrete implementation in
+ * versioned/v{VERSION}/OOOPayloadBuilder.ts
+ */
+export abstract class BaseOOOPayloadBuilder implements IOOOPayloadBuilder {
+ /**
+ * Build the OOO webhook payload.
+ * Each version must implement this method with its specific payload structure.
+ */
+ abstract build(dto: OOOCreatedDTO): WebhookPayload;
+}
diff --git a/packages/features/webhooks/lib/factory/base/BaseRecordingPayloadBuilder.test.ts b/packages/features/webhooks/lib/factory/base/BaseRecordingPayloadBuilder.test.ts
new file mode 100644
index 0000000000..506937353d
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseRecordingPayloadBuilder.test.ts
@@ -0,0 +1,46 @@
+import { describe, it, expect } from "vitest";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../../dto/types";
+import { RecordingPayloadBuilder } from "../versioned/v2021-10-20/RecordingPayloadBuilder";
+
+describe("RecordingPayloadBuilder (v2021-10-20)", () => {
+ const builder = new RecordingPayloadBuilder();
+
+ describe("RECORDING_READY", () => {
+ it("should build payload with download link", () => {
+ const dto: RecordingReadyDTO = {
+ triggerEvent: WebhookTriggerEvents.RECORDING_READY,
+ createdAt: "2024-01-15T10:00:00Z",
+ downloadLink: "https://storage.example.com/recording-123.mp4",
+ };
+
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.RECORDING_READY);
+ expect(payload.createdAt).toBe("2024-01-15T10:00:00Z");
+ expect(payload.payload.downloadLink).toBe("https://storage.example.com/recording-123.mp4");
+ });
+ });
+
+ describe("RECORDING_TRANSCRIPTION_GENERATED", () => {
+ it("should build payload with download links", () => {
+ const dto: TranscriptionGeneratedDTO = {
+ triggerEvent: WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
+ createdAt: "2024-01-15T11:00:00Z",
+ downloadLinks: {
+ transcript: "https://storage.example.com/transcript-123.txt",
+ srt: "https://storage.example.com/transcript-123.srt",
+ },
+ };
+
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED);
+ expect(payload.payload.downloadLinks).toEqual({
+ transcript: "https://storage.example.com/transcript-123.txt",
+ srt: "https://storage.example.com/transcript-123.srt",
+ });
+ });
+ });
+});
diff --git a/packages/features/webhooks/lib/factory/base/BaseRecordingPayloadBuilder.ts b/packages/features/webhooks/lib/factory/base/BaseRecordingPayloadBuilder.ts
new file mode 100644
index 0000000000..043340dc04
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/BaseRecordingPayloadBuilder.ts
@@ -0,0 +1,20 @@
+import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../../dto/types";
+import type { WebhookPayload } from "../types";
+import type { IRecordingPayloadBuilder } from "../versioned/PayloadBuilderFactory";
+
+/**
+ * Abstract base class for recording payload builders.
+ *
+ * This class defines the interface that all version-specific recording payload
+ * builders must implement. It does NOT contain any version-specific payload logic.
+ *
+ * Each webhook version should have its own concrete implementation in
+ * versioned/v{VERSION}/RecordingPayloadBuilder.ts
+ */
+export abstract class BaseRecordingPayloadBuilder implements IRecordingPayloadBuilder {
+ /**
+ * Build the recording webhook payload.
+ * Each version must implement this method with its specific payload structure.
+ */
+ abstract build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload;
+}
diff --git a/packages/features/webhooks/lib/factory/base/index.ts b/packages/features/webhooks/lib/factory/base/index.ts
new file mode 100644
index 0000000000..cdb8f97ac2
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/base/index.ts
@@ -0,0 +1,22 @@
+/**
+ * Abstract base payload builder classes.
+ *
+ * These define the interfaces that version-specific builders must implement.
+ * They do NOT contain version-specific payload logic - that belongs in
+ * the versioned/ directory.
+ *
+ * When creating a new webhook version:
+ * 1. Create a new directory: versioned/v{NEW_VERSION}/
+ * 2. Create concrete builder classes that extend these base classes
+ * 3. Implement the abstract build() method with version-specific payload logic
+ * 4. Register the new version in registry.ts
+ */
+export { BaseBookingPayloadBuilder } from "./BaseBookingPayloadBuilder";
+export { BaseFormPayloadBuilder } from "./BaseFormPayloadBuilder";
+export { BaseMeetingPayloadBuilder } from "./BaseMeetingPayloadBuilder";
+export { BaseRecordingPayloadBuilder } from "./BaseRecordingPayloadBuilder";
+export { BaseOOOPayloadBuilder } from "./BaseOOOPayloadBuilder";
+export { BaseInstantMeetingBuilder } from "./BaseInstantMeetingBuilder";
+export { BaseDelegationPayloadBuilder } from "./BaseDelegationPayloadBuilder";
+
+export type { BookingExtraDataMap, BookingPayloadParams } from "./BaseBookingPayloadBuilder";
diff --git a/packages/features/webhooks/lib/factory/types.ts b/packages/features/webhooks/lib/factory/types.ts
index f9227350bb..1b5c7313bc 100644
--- a/packages/features/webhooks/lib/factory/types.ts
+++ b/packages/features/webhooks/lib/factory/types.ts
@@ -1,4 +1,9 @@
-import type { EventPayloadType, OOOEntryPayloadType, BookingNoShowUpdatedPayload } from "../dto/types";
+import type {
+ EventPayloadType,
+ OOOEntryPayloadType,
+ BookingNoShowUpdatedPayload,
+ DelegationCredentialErrorPayloadType,
+} from "../dto/types";
export interface FormSubmittedPayload {
formId: string;
@@ -90,5 +95,6 @@ export interface WebhookPayload {
| RecordingPayload
| MeetingPayload
| InstantMeetingPayload
- | NoShowWebhookPayload;
+ | NoShowWebhookPayload
+ | DelegationCredentialErrorPayloadType;
}
diff --git a/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.test.ts b/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.test.ts
new file mode 100644
index 0000000000..40d89334fe
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.test.ts
@@ -0,0 +1,256 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { BookingWebhookEventDTO } from "../../dto/types";
+import { PayloadBuilderFactory, type PayloadBuilderSet } from "./PayloadBuilderFactory";
+import * as V2021_10_20 from "./v2021-10-20";
+import { WebhookVersion as WebhookVersionEnum } from "../../interface/IWebhookRepository";
+
+describe("PayloadBuilderFactory", () => {
+ let factory: PayloadBuilderFactory;
+ let defaultBuilders: PayloadBuilderSet;
+
+ beforeEach(() => {
+ defaultBuilders = {
+ booking: new V2021_10_20.BookingPayloadBuilder(),
+ form: new V2021_10_20.FormPayloadBuilder(),
+ ooo: new V2021_10_20.OOOPayloadBuilder(),
+ recording: new V2021_10_20.RecordingPayloadBuilder(),
+ meeting: new V2021_10_20.MeetingPayloadBuilder(),
+ instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
+ };
+
+ factory = new PayloadBuilderFactory(WebhookVersionEnum.V_2021_10_20, defaultBuilders);
+ });
+
+ describe("Constructor", () => {
+ it("should initialize with default version and builders", () => {
+ expect(factory).toBeInstanceOf(PayloadBuilderFactory);
+ expect(factory.getRegisteredVersions()).toContain(WebhookVersionEnum.V_2021_10_20);
+ });
+
+ it("should enforce required parameters at compile-time", () => {
+ // TypeScript ensures parameters are provided - no runtime check needed
+ expect(factory).toBeDefined();
+ expect(factory.getRegisteredVersions()).toHaveLength(1);
+ });
+ });
+
+ describe("Version Registration", () => {
+ it("should register new version with complete builder set", () => {
+ const newVersionBuilders: PayloadBuilderSet = {
+ booking: new V2021_10_20.BookingPayloadBuilder(),
+ form: new V2021_10_20.FormPayloadBuilder(),
+ ooo: new V2021_10_20.OOOPayloadBuilder(),
+ recording: new V2021_10_20.RecordingPayloadBuilder(),
+ meeting: new V2021_10_20.MeetingPayloadBuilder(),
+ instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
+ };
+
+ factory.registerVersion("2024-12-01", newVersionBuilders);
+
+ expect(factory.getRegisteredVersions()).toContain("2024-12-01");
+ expect(factory.getRegisteredVersions()).toHaveLength(2);
+ });
+
+ it("should allow overwriting existing version", () => {
+ const newBuilders: PayloadBuilderSet = {
+ booking: new V2021_10_20.BookingPayloadBuilder(),
+ form: new V2021_10_20.FormPayloadBuilder(),
+ ooo: new V2021_10_20.OOOPayloadBuilder(),
+ recording: new V2021_10_20.RecordingPayloadBuilder(),
+ meeting: new V2021_10_20.MeetingPayloadBuilder(),
+ instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
+ };
+
+ factory.registerVersion(WebhookVersionEnum.V_2021_10_20, newBuilders);
+
+ expect(factory.getRegisteredVersions()).toHaveLength(1);
+ });
+ });
+
+ describe("Builder Routing", () => {
+ it("should route booking events to booking builder", () => {
+ const bookingTriggers = [
+ WebhookTriggerEvents.BOOKING_CREATED,
+ WebhookTriggerEvents.BOOKING_CANCELLED,
+ WebhookTriggerEvents.BOOKING_REQUESTED,
+ WebhookTriggerEvents.BOOKING_RESCHEDULED,
+ WebhookTriggerEvents.BOOKING_REJECTED,
+ WebhookTriggerEvents.BOOKING_PAID,
+ WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
+ WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
+ ];
+
+ bookingTriggers.forEach((trigger) => {
+ const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, trigger);
+ expect(builder).toBe(defaultBuilders.booking);
+ });
+ });
+
+ it("should route form events to form builder", () => {
+ const formTriggers = [
+ WebhookTriggerEvents.FORM_SUBMITTED,
+ WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT,
+ ];
+
+ formTriggers.forEach((trigger) => {
+ const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, trigger);
+ expect(builder).toBe(defaultBuilders.form);
+ });
+ });
+
+ it("should route OOO events to OOO builder", () => {
+ const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.OOO_CREATED);
+ expect(builder).toBe(defaultBuilders.ooo);
+ });
+
+ it("should route recording events to recording builder", () => {
+ const recordingTriggers = [
+ WebhookTriggerEvents.RECORDING_READY,
+ WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED,
+ ];
+
+ recordingTriggers.forEach((trigger) => {
+ const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, trigger);
+ expect(builder).toBe(defaultBuilders.recording);
+ });
+ });
+
+ it("should route meeting events to meeting builder", () => {
+ const meetingTriggers = [WebhookTriggerEvents.MEETING_STARTED, WebhookTriggerEvents.MEETING_ENDED];
+
+ meetingTriggers.forEach((trigger) => {
+ const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, trigger);
+ expect(builder).toBe(defaultBuilders.meeting);
+ });
+ });
+
+ it("should route instant meeting events to instant meeting builder", () => {
+ const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.INSTANT_MEETING);
+ expect(builder).toBe(defaultBuilders.instantMeeting);
+ });
+ });
+
+ describe("Fallback Behavior", () => {
+ it("should fallback to default version when requested version not found", () => {
+ const builder = factory.getBuilder(WebhookVersionEnum.V_2099_99_99, WebhookTriggerEvents.BOOKING_CREATED);
+
+ // Should get default builder, not throw
+ expect(builder).toBe(defaultBuilders.booking);
+ });
+
+ it("should log warning when falling back to default", () => {
+ // Just verify it doesn't throw - logging is tested elsewhere
+ expect(() => {
+ factory.getBuilder("invalid-version", WebhookTriggerEvents.BOOKING_CREATED);
+ }).not.toThrow();
+ });
+
+ it("should never return undefined builder", () => {
+ const unknownVersion = WebhookVersionEnum.V_2099_99_99;
+ const builder = factory.getBuilder(unknownVersion, WebhookTriggerEvents.BOOKING_CREATED);
+
+ expect(builder).toBeDefined();
+ expect(builder).toBe(defaultBuilders.booking);
+ });
+ });
+
+ describe("Type Safety", () => {
+ it("should return correctly typed builder for each trigger", () => {
+ // These type assertions verify compile-time type safety
+ const bookingBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
+ const formBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.FORM_SUBMITTED);
+ const oooBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.OOO_CREATED);
+ const recordingBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.RECORDING_READY);
+ const meetingBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.MEETING_STARTED);
+ const instantBuilder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.INSTANT_MEETING);
+
+ // Runtime verification
+ expect(bookingBuilder).toBeDefined();
+ expect(formBuilder).toBeDefined();
+ expect(oooBuilder).toBeDefined();
+ expect(recordingBuilder).toBeDefined();
+ expect(meetingBuilder).toBeDefined();
+ expect(instantBuilder).toBeDefined();
+ });
+
+ it("should build valid payload with correctly typed DTO", () => {
+ const mockDTO: BookingWebhookEventDTO = {
+ triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
+ createdAt: "2024-01-15T10:00:00Z",
+ booking: {
+ id: 1,
+ eventTypeId: 1,
+ userId: 1,
+ smsReminderNumber: null,
+ },
+ eventType: {
+ eventTitle: "Test Event",
+ eventDescription: "Test Description",
+ requiresConfirmation: false,
+ price: 0,
+ currency: "USD",
+ length: 30,
+ },
+ evt: {
+ type: "test-event",
+ title: "Test Meeting",
+ description: "Meeting description",
+ startTime: "2024-01-15T10:00:00Z",
+ endTime: "2024-01-15T10:30:00Z",
+ organizer: {
+ id: 1,
+ email: "organizer@test.com",
+ name: "Test Organizer",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ attendees: [
+ {
+ email: "attendee@test.com",
+ name: "Test Attendee",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ ],
+ uid: "booking-uid-123",
+ customInputs: {},
+ responses: {},
+ userFieldsResponses: {},
+ },
+ };
+
+ const builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
+ const payload = builder.build(mockDTO);
+
+ expect(payload).toBeDefined();
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CREATED);
+ expect(payload.payload).toBeDefined();
+ });
+ });
+
+ describe("Multiple Versions", () => {
+ it("should support multiple versions simultaneously", () => {
+ const v2Builders: PayloadBuilderSet = {
+ booking: new V2021_10_20.BookingPayloadBuilder(),
+ form: new V2021_10_20.FormPayloadBuilder(),
+ ooo: new V2021_10_20.OOOPayloadBuilder(),
+ recording: new V2021_10_20.RecordingPayloadBuilder(),
+ meeting: new V2021_10_20.MeetingPayloadBuilder(),
+ instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
+ };
+
+ factory.registerVersion(WebhookVersionEnum.V_2024_12_01, v2Builders);
+
+ // Both versions should work
+ const v1Builder = factory.getBuilder(WebhookVersionEnum.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
+ const v2Builder = factory.getBuilder(WebhookVersionEnum.V_2024_12_01, WebhookTriggerEvents.BOOKING_CREATED);
+
+ expect(v1Builder).toBe(defaultBuilders.booking);
+ expect(v2Builder).toBe(v2Builders.booking);
+ expect(v1Builder).not.toBe(v2Builder); // Different instances
+ });
+ });
+});
+
diff --git a/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts b/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts
new file mode 100644
index 0000000000..f1b55f08af
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/PayloadBuilderFactory.ts
@@ -0,0 +1,257 @@
+import logger from "@calcom/lib/logger";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { WebhookVersion } from "../../interface/IWebhookRepository";
+
+import type {
+ AfterGuestsNoShowDTO,
+ AfterHostsNoShowDTO,
+ BookingWebhookEventDTO,
+ DelegationCredentialErrorDTO,
+ FormSubmittedDTO,
+ FormSubmittedNoEventDTO,
+ InstantMeetingDTO,
+ MeetingEndedDTO,
+ MeetingStartedDTO,
+ OOOCreatedDTO,
+ RecordingReadyDTO,
+ TranscriptionGeneratedDTO,
+ WebhookEventDTO,
+} from "../../dto/types";
+import type { WebhookPayload } from "../types";
+
+const log = logger.getSubLogger({ prefix: ["WebhookPayloadBuilderFactory"] });
+
+/**
+ * Generic base interface for all payload builders
+ * Ensures type-safe input DTOs and output payloads
+ */
+export interface IPayloadBuilder {
+ build(dto: TInput): WebhookPayload;
+}
+
+/**
+ * Type-safe interfaces for specific payload builders
+ */
+export interface IBookingPayloadBuilder extends IPayloadBuilder {
+ build(dto: BookingWebhookEventDTO): WebhookPayload;
+}
+
+export interface IFormPayloadBuilder extends IPayloadBuilder {
+ build(dto: FormSubmittedDTO | FormSubmittedNoEventDTO): WebhookPayload;
+}
+
+export interface IOOOPayloadBuilder extends IPayloadBuilder {
+ build(dto: OOOCreatedDTO): WebhookPayload;
+}
+
+export interface IRecordingPayloadBuilder
+ extends IPayloadBuilder {
+ build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload;
+}
+
+export interface IMeetingPayloadBuilder
+ extends IPayloadBuilder {
+ build(
+ dto: MeetingStartedDTO | MeetingEndedDTO | AfterHostsNoShowDTO | AfterGuestsNoShowDTO
+ ): WebhookPayload;
+}
+
+export interface IInstantMeetingBuilder extends IPayloadBuilder {
+ build(dto: InstantMeetingDTO): WebhookPayload;
+}
+
+export interface IDelegationPayloadBuilder extends IPayloadBuilder {
+ build(dto: DelegationCredentialErrorDTO): WebhookPayload;
+}
+
+/**
+ * Set of all payload builders for a specific webhook version
+ * Each builder is properly typed for its respective event DTOs
+ */
+export interface PayloadBuilderSet {
+ booking: IBookingPayloadBuilder;
+ form: IFormPayloadBuilder;
+ ooo: IOOOPayloadBuilder;
+ recording: IRecordingPayloadBuilder;
+ meeting: IMeetingPayloadBuilder;
+ instantMeeting: IInstantMeetingBuilder;
+ delegation: IDelegationPayloadBuilder;
+}
+
+/**
+ * Builder categories - used for explicit routing
+ */
+type BuilderCategory = keyof PayloadBuilderSet;
+
+/**
+ * Explicit mapping of trigger events to builder categories.
+ * This prevents ambiguous routing - each event has exactly one handler.
+ *
+ * Note: Record ensures all events are mapped.
+ */
+const TRIGGER_TO_BUILDER_CATEGORY: Record = {
+ // Booking events
+ [WebhookTriggerEvents.BOOKING_CREATED]: "booking",
+ [WebhookTriggerEvents.BOOKING_RESCHEDULED]: "booking",
+ [WebhookTriggerEvents.BOOKING_CANCELLED]: "booking",
+ [WebhookTriggerEvents.BOOKING_REJECTED]: "booking",
+ [WebhookTriggerEvents.BOOKING_REQUESTED]: "booking",
+ [WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED]: "booking",
+ [WebhookTriggerEvents.BOOKING_PAID]: "booking",
+ [WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED]: "booking",
+
+ // Form events
+ [WebhookTriggerEvents.FORM_SUBMITTED]: "form",
+ [WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT]: "form",
+
+ // OOO events
+ [WebhookTriggerEvents.OOO_CREATED]: "ooo",
+
+ // Recording events
+ [WebhookTriggerEvents.RECORDING_READY]: "recording",
+ [WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED]: "recording",
+
+ // Meeting events
+ [WebhookTriggerEvents.MEETING_STARTED]: "meeting",
+ [WebhookTriggerEvents.MEETING_ENDED]: "meeting",
+ [WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW]: "meeting",
+ [WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW]: "meeting",
+
+ // Instant meeting events
+ [WebhookTriggerEvents.INSTANT_MEETING]: "instantMeeting",
+
+ // Delegation events
+ [WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR]: "delegation",
+};
+
+/**
+ * Type mapping: TriggerEvent → DTO type
+ * Used for compile-time type safety
+ *
+ * Note: WebhookTriggerEvents is a const object, so we use typeof to extract literal types
+ */
+type BookingTriggerEvents =
+ | typeof WebhookTriggerEvents.BOOKING_CREATED
+ | typeof WebhookTriggerEvents.BOOKING_RESCHEDULED
+ | typeof WebhookTriggerEvents.BOOKING_CANCELLED
+ | typeof WebhookTriggerEvents.BOOKING_REJECTED
+ | typeof WebhookTriggerEvents.BOOKING_REQUESTED
+ | typeof WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED
+ | typeof WebhookTriggerEvents.BOOKING_PAID
+ | typeof WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED;
+
+type DelegationTriggerEvents = typeof WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR;
+
+type FormTriggerEvents =
+ | typeof WebhookTriggerEvents.FORM_SUBMITTED
+ | typeof WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT;
+
+type OOOTriggerEvents = typeof WebhookTriggerEvents.OOO_CREATED;
+
+type RecordingTriggerEvents =
+ | typeof WebhookTriggerEvents.RECORDING_READY
+ | typeof WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED;
+
+type MeetingTriggerEvents =
+ | typeof WebhookTriggerEvents.MEETING_STARTED
+ | typeof WebhookTriggerEvents.MEETING_ENDED
+ | typeof WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW
+ | typeof WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW;
+
+type InstantMeetingTriggerEvents = typeof WebhookTriggerEvents.INSTANT_MEETING;
+
+/**
+ * Factory that routes to version-specific payload builders
+ *
+ * Uses explicit trigger→builder mapping for:
+ * - No ambiguous routing (each event has exactly one handler)
+ * - Compile-time validation (all events must be mapped)
+ * - Type-safe builder selection via overloads
+ *
+ * @example
+ * ```ts
+ * const factory = createPayloadBuilderFactory();
+ * const builder = factory.getBuilder(version, WebhookTriggerEvents.BOOKING_CREATED);
+ * const payload = builder.build(bookingDTO); // Type-safe: expects BookingWebhookEventDTO
+ * ```
+ */
+export class PayloadBuilderFactory {
+ private builders: Map;
+ private readonly defaultBuilderSet: PayloadBuilderSet;
+ private readonly defaultVersion: WebhookVersion;
+
+ /**
+ * @param defaultVersion - The version to use as fallback when requested version is not registered
+ * @param defaultBuilderSet - Required builders for the default version. Guarantees fallback always works.
+ */
+ constructor(defaultVersion: WebhookVersion, defaultBuilderSet: PayloadBuilderSet) {
+ this.defaultVersion = defaultVersion;
+ this.defaultBuilderSet = defaultBuilderSet;
+ this.builders = new Map();
+ this.builders.set(defaultVersion, defaultBuilderSet);
+ }
+
+ /**
+ * Register a complete set of payload builders for a specific version
+ */
+ registerVersion(version: WebhookVersion, builderSet: PayloadBuilderSet): void {
+ this.builders.set(version, builderSet);
+ }
+
+ /**
+ * Get the builder set for a version, falling back to default if not registered
+ */
+ private getBuilderSet(version: WebhookVersion): PayloadBuilderSet {
+ const requestedBuilderSet = this.builders.get(version);
+
+ if (!requestedBuilderSet) {
+ log.warn(
+ `Webhook version "${version}" not registered, falling back to default version "${this.defaultVersion}". ` +
+ `Available versions: ${Array.from(this.builders.keys()).join(", ")}`
+ );
+ return this.defaultBuilderSet;
+ }
+
+ return requestedBuilderSet;
+ }
+
+ /**
+ * Type-safe builder getters - overloaded for compile-time DTO type safety
+ */
+ getBuilder(version: WebhookVersion, triggerEvent: BookingTriggerEvents): IBookingPayloadBuilder;
+ getBuilder(version: WebhookVersion, triggerEvent: FormTriggerEvents): IFormPayloadBuilder;
+ getBuilder(version: WebhookVersion, triggerEvent: OOOTriggerEvents): IOOOPayloadBuilder;
+ getBuilder(version: WebhookVersion, triggerEvent: RecordingTriggerEvents): IRecordingPayloadBuilder;
+ getBuilder(version: WebhookVersion, triggerEvent: MeetingTriggerEvents): IMeetingPayloadBuilder;
+ getBuilder(version: WebhookVersion, triggerEvent: InstantMeetingTriggerEvents): IInstantMeetingBuilder;
+ getBuilder(version: WebhookVersion, triggerEvent: DelegationTriggerEvents): IDelegationPayloadBuilder;
+ getBuilder(version: WebhookVersion, triggerEvent: WebhookTriggerEvents): IPayloadBuilder;
+ getBuilder(version: WebhookVersion, triggerEvent: WebhookTriggerEvents): IPayloadBuilder {
+ const builderSet = this.getBuilderSet(version);
+ const category = TRIGGER_TO_BUILDER_CATEGORY[triggerEvent];
+
+ return builderSet[category];
+ }
+
+ /**
+ * Get all registered versions
+ */
+ getRegisteredVersions(): WebhookVersion[] {
+ return Array.from(this.builders.keys());
+ }
+
+ /**
+ * Check if a version is supported
+ */
+ isVersionSupported(version: WebhookVersion): boolean {
+ return this.builders.has(version);
+ }
+
+ /**
+ * Get the default/fallback version
+ */
+ getDefaultVersion(): WebhookVersion {
+ return this.defaultVersion;
+ }
+}
diff --git a/packages/features/webhooks/lib/factory/versioned/registry.test.ts b/packages/features/webhooks/lib/factory/versioned/registry.test.ts
new file mode 100644
index 0000000000..4e4316b547
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/registry.test.ts
@@ -0,0 +1,147 @@
+import { describe, it, expect } from "vitest";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import { DEFAULT_WEBHOOK_VERSION, WebhookVersion } from "../../interface/IWebhookRepository";
+import { createPayloadBuilderFactory } from "./registry";
+import * as V2021_10_20 from "./v2021-10-20";
+
+describe("Payload Builder Registry", () => {
+ describe("createPayloadBuilderFactory", () => {
+ it("should create factory with default version registered", () => {
+ const factory = createPayloadBuilderFactory();
+
+ expect(factory).toBeDefined();
+ expect(factory.getRegisteredVersions()).toContain(DEFAULT_WEBHOOK_VERSION);
+ });
+
+ it("should register all required builders for default version", () => {
+ const factory = createPayloadBuilderFactory();
+
+ // Test each builder category by trigger event
+ const bookingBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.BOOKING_CREATED);
+ const formBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.FORM_SUBMITTED);
+ const oooBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.OOO_CREATED);
+ const recordingBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.RECORDING_READY);
+ const meetingBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.MEETING_STARTED);
+ const instantBuilder = factory.getBuilder(DEFAULT_WEBHOOK_VERSION, WebhookTriggerEvents.INSTANT_MEETING);
+
+ expect(bookingBuilder).toBeDefined();
+ expect(formBuilder).toBeDefined();
+ expect(oooBuilder).toBeDefined();
+ expect(recordingBuilder).toBeDefined();
+ expect(meetingBuilder).toBeDefined();
+ expect(instantBuilder).toBeDefined();
+ });
+
+ it("should register v2021-10-20 builders", () => {
+ const factory = createPayloadBuilderFactory();
+
+ const builder = factory.getBuilder(WebhookVersion.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
+ expect(builder).toBeInstanceOf(V2021_10_20.BookingPayloadBuilder);
+ });
+
+ it("should return same factory instance characteristics each time", () => {
+ const factory1 = createPayloadBuilderFactory();
+ const factory2 = createPayloadBuilderFactory();
+
+ // Both should have same registered versions
+ expect(factory1.getRegisteredVersions()).toEqual(factory2.getRegisteredVersions());
+ });
+
+ it("should support fallback to default version", () => {
+ const factory = createPayloadBuilderFactory();
+
+ // Request non-existent version (cast to WebhookVersion for testing fallback)
+ const builder = factory.getBuilder("9999-99-99" as WebhookVersion, WebhookTriggerEvents.BOOKING_CREATED);
+
+ // Should get default builder, not throw
+ expect(builder).toBeDefined();
+ expect(builder).toBeInstanceOf(V2021_10_20.BookingPayloadBuilder);
+ });
+ });
+
+ describe("Factory Composition Root", () => {
+ it("should be the single source of truth for version registration", () => {
+ const factory = createPayloadBuilderFactory();
+
+ // Should only have default version initially
+ expect(factory.getRegisteredVersions()).toHaveLength(1);
+ expect(factory.getRegisteredVersions()[0]).toBe(DEFAULT_WEBHOOK_VERSION);
+ });
+
+ it("should allow extending with new versions", () => {
+ const factory = createPayloadBuilderFactory();
+ const NEW_VERSION = "2024-12-01" as WebhookVersion;
+
+ // Register new version
+ const newVersionBuilders = {
+ booking: new V2021_10_20.BookingPayloadBuilder(),
+ form: new V2021_10_20.FormPayloadBuilder(),
+ ooo: new V2021_10_20.OOOPayloadBuilder(),
+ recording: new V2021_10_20.RecordingPayloadBuilder(),
+ meeting: new V2021_10_20.MeetingPayloadBuilder(),
+ instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
+ delegation: new V2021_10_20.DelegationPayloadBuilder(),
+ };
+
+ factory.registerVersion(NEW_VERSION, newVersionBuilders);
+
+ expect(factory.getRegisteredVersions()).toContain(NEW_VERSION);
+ expect(factory.getRegisteredVersions()).toHaveLength(2);
+ });
+
+ it("should maintain independence of builder instances per version", () => {
+ const factory = createPayloadBuilderFactory();
+ const NEW_VERSION = "2024-12-01" as WebhookVersion;
+
+ // Register second version with new instances
+ const v2Builders = {
+ booking: new V2021_10_20.BookingPayloadBuilder(),
+ form: new V2021_10_20.FormPayloadBuilder(),
+ ooo: new V2021_10_20.OOOPayloadBuilder(),
+ recording: new V2021_10_20.RecordingPayloadBuilder(),
+ meeting: new V2021_10_20.MeetingPayloadBuilder(),
+ instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
+ delegation: new V2021_10_20.DelegationPayloadBuilder(),
+ };
+
+ factory.registerVersion(NEW_VERSION, v2Builders);
+
+ const v1Builder = factory.getBuilder(WebhookVersion.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
+ const v2Builder = factory.getBuilder(NEW_VERSION, WebhookTriggerEvents.BOOKING_CREATED);
+
+ // Should be different instances
+ expect(v1Builder).not.toBe(v2Builder);
+ });
+ });
+
+ describe("DI Integration", () => {
+ it("should be suitable for DI container registration", () => {
+ // Factory function is pure and stateless - perfect for DI
+ const factory1 = createPayloadBuilderFactory();
+ const factory2 = createPayloadBuilderFactory();
+
+ // Both should work independently
+ expect(factory1).toBeDefined();
+ expect(factory2).toBeDefined();
+
+ // And not interfere with each other
+ factory1.registerVersion("test-1", {
+ booking: new V2021_10_20.BookingPayloadBuilder(),
+ form: new V2021_10_20.FormPayloadBuilder(),
+ ooo: new V2021_10_20.OOOPayloadBuilder(),
+ recording: new V2021_10_20.RecordingPayloadBuilder(),
+ meeting: new V2021_10_20.MeetingPayloadBuilder(),
+ instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
+ });
+
+ expect(factory1.getRegisteredVersions()).not.toEqual(factory2.getRegisteredVersions());
+ });
+
+ it("should initialize without external dependencies", () => {
+ // Factory creation should not throw or require external setup
+ expect(() => createPayloadBuilderFactory()).not.toThrow();
+ });
+ });
+});
+
diff --git a/packages/features/webhooks/lib/factory/versioned/registry.ts b/packages/features/webhooks/lib/factory/versioned/registry.ts
new file mode 100644
index 0000000000..73df7abc5e
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/registry.ts
@@ -0,0 +1,49 @@
+import { DEFAULT_WEBHOOK_VERSION } from "../../interface/IWebhookRepository";
+import { PayloadBuilderFactory } from "./PayloadBuilderFactory";
+import * as V2021_10_20 from "./v2021-10-20";
+
+// Re-export for consumers
+export { DEFAULT_WEBHOOK_VERSION } from "../../interface/IWebhookRepository";
+
+/**
+ * Create and initialize a PayloadBuilderFactory with all registered versions.
+ *
+ * This is the central registry (composition root) for all webhook versions.
+ * The factory is fully configured here - consumers just use it.
+ *
+ * @returns Fully configured PayloadBuilderFactory instance
+ *
+ * @example Adding a new webhook version
+ * 1. Create directory: versioned/v2024-12-01/
+ * 2. Implement builders in that directory
+ * 3. Import and register here:
+ * ```ts
+ *
+ * import * as V2024_12_01 from "./v2024-12-01";
+ *
+ * factory.registerVersion(WebhookVersion.V_2024_12_01, {
+ * booking: new V2024_12_01.BookingPayloadBuilder(),
+ * form: new V2024_12_01.FormPayloadBuilder(),
+ * ooo: new V2024_12_01.OOOPayloadBuilder(),
+ * recording: new V2024_12_01.RecordingPayloadBuilder(),
+ * meeting: new V2024_12_01.MeetingPayloadBuilder(),
+ * instantMeeting: new V2024_12_01.InstantMeetingBuilder(),
+ * delegation: new V2024_12_01.DelegationPayloadBuilder(),
+ * });
+ * ```
+ */
+export function createPayloadBuilderFactory(): PayloadBuilderFactory {
+ const defaultBuilders = {
+ booking: new V2021_10_20.BookingPayloadBuilder(),
+ form: new V2021_10_20.FormPayloadBuilder(),
+ ooo: new V2021_10_20.OOOPayloadBuilder(),
+ recording: new V2021_10_20.RecordingPayloadBuilder(),
+ meeting: new V2021_10_20.MeetingPayloadBuilder(),
+ instantMeeting: new V2021_10_20.InstantMeetingBuilder(),
+ delegation: new V2021_10_20.DelegationPayloadBuilder(),
+ };
+
+ const factory = new PayloadBuilderFactory(DEFAULT_WEBHOOK_VERSION, defaultBuilders);
+
+ return factory;
+}
diff --git a/packages/features/webhooks/lib/factory/versioned/v2021-10-20/BookingPayloadBuilder.test.ts b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/BookingPayloadBuilder.test.ts
new file mode 100644
index 0000000000..7816527dad
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/BookingPayloadBuilder.test.ts
@@ -0,0 +1,153 @@
+import { describe, it, expect, vi } from "vitest";
+import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
+import type { CalendarEvent } from "@calcom/types/Calendar";
+
+import type { BookingWebhookEventDTO, EventTypeInfo } from "../../../dto/types";
+import { BookingPayloadBuilder } from "./BookingPayloadBuilder";
+
+vi.mock("@calcom/lib/dayjs", () => ({
+ getUTCOffsetByTimezone: vi.fn(() => 0),
+}));
+
+describe("v2021-10-20/BookingPayloadBuilder", () => {
+ const builder = new BookingPayloadBuilder();
+
+ const mockEventType: EventTypeInfo = {
+ eventTitle: "Test Event",
+ eventDescription: "Test Description",
+ requiresConfirmation: false,
+ price: 0,
+ currency: "USD",
+ length: 30,
+ };
+
+ const mockCalendarEvent: CalendarEvent = {
+ type: "test-event",
+ title: "Test Meeting",
+ description: "Meeting description",
+ additionalNotes: "Additional notes",
+ startTime: "2024-01-15T10:00:00Z",
+ endTime: "2024-01-15T10:30:00Z",
+ organizer: {
+ id: 1,
+ email: "organizer@test.com",
+ name: "Test Organizer",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ attendees: [
+ {
+ email: "attendee@test.com",
+ name: "Test Attendee",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ ],
+ location: "https://cal.com/video/123",
+ uid: "booking-uid-123",
+ customInputs: {},
+ responses: {},
+ userFieldsResponses: {},
+ };
+
+ const createMockDTO = (
+ triggerEvent: WebhookTriggerEvents,
+ extra: Partial = {}
+ ): BookingWebhookEventDTO => ({
+ triggerEvent,
+ createdAt: "2024-01-15T10:00:00Z",
+ booking: {
+ id: 1,
+ eventTypeId: 1,
+ userId: 1,
+ smsReminderNumber: null,
+ },
+ eventType: mockEventType,
+ evt: mockCalendarEvent,
+ ...extra,
+ });
+
+ it("should be instance of correct class", () => {
+ expect(builder).toBeInstanceOf(BookingPayloadBuilder);
+ });
+
+ it("should correctly build BOOKING_CREATED payload", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CREATED);
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CREATED);
+ expect(payload.payload.status).toBe(BookingStatus.ACCEPTED);
+ expect(payload.payload.bookingId).toBe(1);
+ expect(payload.payload.title).toBe("Test Meeting");
+ });
+
+ it("should correctly build BOOKING_CANCELLED payload", () => {
+ const dto = createMockDTO(WebhookTriggerEvents.BOOKING_CANCELLED, {
+ cancelledBy: "user@test.com",
+ cancellationReason: "Schedule conflict",
+ });
+ const payload = builder.build(dto);
+
+ expect(payload.triggerEvent).toBe(WebhookTriggerEvents.BOOKING_CANCELLED);
+ expect(payload.payload.status).toBe(BookingStatus.CANCELLED);
+ expect(payload.payload.cancelledBy).toBe("user@test.com");
+ expect(payload.payload.cancellationReason).toBe("Schedule conflict");
+ });
+
+ it("should handle all booking trigger events", () => {
+ const triggers = [
+ WebhookTriggerEvents.BOOKING_CREATED,
+ WebhookTriggerEvents.BOOKING_CANCELLED,
+ WebhookTriggerEvents.BOOKING_REQUESTED,
+ WebhookTriggerEvents.BOOKING_RESCHEDULED,
+ WebhookTriggerEvents.BOOKING_REJECTED,
+ WebhookTriggerEvents.BOOKING_PAID,
+ WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED,
+ WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED,
+ ];
+
+ triggers.forEach((trigger) => {
+ let dto: BookingWebhookEventDTO;
+
+ if (trigger === WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED) {
+ dto = {
+ triggerEvent: trigger,
+ createdAt: "2024-01-15T10:00:00Z",
+ bookingUid: "booking-uid-123",
+ bookingId: 1,
+ attendees: [{ email: "attendee@test.com", noShow: true }],
+ message: "No-show",
+ } as BookingWebhookEventDTO;
+ } else {
+ dto = createMockDTO(trigger);
+ }
+
+ const payload = builder.build(dto);
+ expect(payload.triggerEvent).toBe(trigger);
+ expect(payload.payload).toBeDefined();
+ });
+ });
+
+ it("should never throw on missing optional fields", () => {
+ // Test with minimal DTO
+ const minimalDTO: BookingWebhookEventDTO = {
+ triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
+ createdAt: "2024-01-15T10:00:00Z",
+ booking: {
+ id: 1,
+ eventTypeId: 1,
+ userId: 1,
+ smsReminderNumber: null,
+ },
+ eventType: mockEventType,
+ evt: {
+ ...mockCalendarEvent,
+ organizer: null,
+ attendees: [],
+ },
+ };
+
+ expect(() => builder.build(minimalDTO)).not.toThrow();
+ });
+});
+
diff --git a/packages/features/webhooks/lib/factory/versioned/v2021-10-20/BookingPayloadBuilder.ts b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/BookingPayloadBuilder.ts
new file mode 100644
index 0000000000..8f2d65e050
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/BookingPayloadBuilder.ts
@@ -0,0 +1,188 @@
+import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
+import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { BookingWebhookEventDTO } from "../../../dto/types";
+import type { WebhookPayload } from "../../types";
+import {
+ BaseBookingPayloadBuilder,
+ type BookingExtraDataMap,
+ type BookingPayloadParams,
+} from "../../base/BaseBookingPayloadBuilder";
+
+/**
+ * Booking payload builder for webhook version 2021-10-20.
+ *
+ * This is the initial webhook payload format. It includes:
+ * - Full CalendarEvent data spread into payload
+ * - UTC offset calculations for organizer and attendees
+ * - Event type metadata (title, description, price, etc.)
+ * - Trigger-specific extra fields (cancellation reason, reschedule info, etc.)
+ */
+export class BookingPayloadBuilder extends BaseBookingPayloadBuilder {
+ /**
+ * Build the complete booking webhook payload for v2021-10-20.
+ */
+ build(dto: BookingWebhookEventDTO): WebhookPayload {
+ switch (dto.triggerEvent) {
+ case WebhookTriggerEvents.BOOKING_CREATED:
+ return this.buildBookingPayload({
+ booking: dto.booking,
+ eventType: dto.eventType,
+ evt: dto.evt,
+ status: BookingStatus.ACCEPTED,
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ });
+
+ case WebhookTriggerEvents.BOOKING_CANCELLED:
+ return this.buildBookingPayload({
+ booking: dto.booking,
+ eventType: dto.eventType,
+ evt: dto.evt,
+ status: BookingStatus.CANCELLED,
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ extra: {
+ cancelledBy: dto.cancelledBy,
+ cancellationReason: dto.cancellationReason,
+ },
+ });
+
+ case WebhookTriggerEvents.BOOKING_REQUESTED:
+ return this.buildBookingPayload({
+ booking: dto.booking,
+ eventType: dto.eventType,
+ evt: dto.evt,
+ status: BookingStatus.PENDING,
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ });
+
+ case WebhookTriggerEvents.BOOKING_REJECTED:
+ return this.buildBookingPayload({
+ booking: dto.booking,
+ eventType: dto.eventType,
+ evt: dto.evt,
+ status: BookingStatus.REJECTED,
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ });
+
+ case WebhookTriggerEvents.BOOKING_RESCHEDULED:
+ return this.buildBookingPayload({
+ booking: dto.booking,
+ eventType: dto.eventType,
+ evt: dto.evt,
+ status: BookingStatus.ACCEPTED,
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ extra: {
+ rescheduleId: dto.rescheduleId,
+ rescheduleUid: dto.rescheduleUid,
+ rescheduleStartTime: dto.rescheduleStartTime,
+ rescheduleEndTime: dto.rescheduleEndTime,
+ rescheduledBy: dto.rescheduledBy,
+ },
+ });
+
+ case WebhookTriggerEvents.BOOKING_PAID:
+ return this.buildBookingPayload({
+ booking: dto.booking,
+ eventType: dto.eventType,
+ evt: dto.evt,
+ status: BookingStatus.ACCEPTED,
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ extra: {
+ paymentId: dto.paymentId,
+ paymentData: dto.paymentData,
+ },
+ });
+
+ case WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED:
+ return this.buildBookingPayload({
+ booking: dto.booking,
+ eventType: dto.eventType,
+ evt: dto.evt,
+ status: BookingStatus.ACCEPTED,
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ extra: {
+ paymentId: dto.paymentId,
+ paymentData: dto.paymentData,
+ },
+ });
+
+ case WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED:
+ return this.buildNoShowPayload(dto);
+
+ default: {
+ const _exhaustiveCheck: never = dto;
+ throw new Error(`Unsupported booking trigger: ${JSON.stringify(_exhaustiveCheck)}`);
+ }
+ }
+ }
+
+ /**
+ * Build the standard booking payload structure for v2021-10-20.
+ */
+ private buildBookingPayload(
+ params: BookingPayloadParams
+ ): WebhookPayload {
+ const utcOffsetOrganizer = getUTCOffsetByTimezone(params.evt.organizer?.timeZone, params.evt.startTime);
+ const organizer = { ...params.evt.organizer, utcOffset: utcOffsetOrganizer };
+
+ return {
+ triggerEvent: params.triggerEvent,
+ createdAt: params.createdAt,
+ payload: {
+ ...params.evt,
+ bookingId: params.booking.id,
+ startTime: params.evt.startTime,
+ endTime: params.evt.endTime,
+ title: params.evt.title,
+ type: params.evt.type,
+ organizer,
+ attendees:
+ params.evt.attendees?.map((a) => ({
+ ...a,
+ utcOffset: getUTCOffsetByTimezone(a.timeZone, params.evt.startTime),
+ })) ?? [],
+ location: params.evt.location,
+ uid: params.evt.uid,
+ customInputs: params.evt.customInputs,
+ responses: params.evt.responses,
+ userFieldsResponses: params.evt.userFieldsResponses,
+ status: params.status,
+ eventTitle: params.eventType?.eventTitle,
+ eventDescription: params.eventType?.eventDescription,
+ requiresConfirmation: params.eventType?.requiresConfirmation,
+ price: params.eventType?.price,
+ currency: params.eventType?.currency,
+ length: params.eventType?.length,
+ smsReminderNumber: params.booking.smsReminderNumber || undefined,
+ description: params.evt.description || params.evt.additionalNotes,
+ ...(params.extra || {}),
+ },
+ };
+ }
+
+ /**
+ * Build the no-show updated payload for v2021-10-20.
+ */
+ private buildNoShowPayload(dto: BookingWebhookEventDTO): WebhookPayload {
+ if (dto.triggerEvent !== WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED) {
+ throw new Error("Invalid trigger event for no-show payload");
+ }
+ return {
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ payload: {
+ bookingUid: dto.bookingUid,
+ bookingId: dto.bookingId,
+ attendees: dto.attendees,
+ message: dto.message,
+ },
+ };
+ }
+}
diff --git a/packages/features/webhooks/lib/factory/versioned/v2021-10-20/DelegationPayloadBuilder.ts b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/DelegationPayloadBuilder.ts
new file mode 100644
index 0000000000..5103c74999
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/DelegationPayloadBuilder.ts
@@ -0,0 +1,31 @@
+import type { DelegationCredentialErrorDTO, DelegationCredentialErrorPayloadType } from "../../../dto/types";
+import type { WebhookPayload } from "../../types";
+import { BaseDelegationPayloadBuilder } from "../../base/BaseDelegationPayloadBuilder";
+
+/**
+ * Delegation payload builder for webhook version 2021-10-20.
+ *
+ * Handles DELEGATION_CREDENTIAL_ERROR events with:
+ * - Error details
+ * - Credential information
+ * - User information
+ */
+export class DelegationPayloadBuilder extends BaseDelegationPayloadBuilder {
+ /**
+ * Build the delegation credential error webhook payload for v2021-10-20.
+ */
+ build(dto: DelegationCredentialErrorDTO): WebhookPayload {
+ const payload: DelegationCredentialErrorPayloadType = {
+ error: dto.error,
+ credential: dto.credential,
+ user: dto.user,
+ };
+
+ return {
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ payload,
+ };
+ }
+}
+
diff --git a/packages/features/webhooks/lib/factory/FormPayloadBuilder.ts b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/FormPayloadBuilder.ts
similarity index 56%
rename from packages/features/webhooks/lib/factory/FormPayloadBuilder.ts
rename to packages/features/webhooks/lib/factory/versioned/v2021-10-20/FormPayloadBuilder.ts
index d1dae27bd7..2ca2e92086 100644
--- a/packages/features/webhooks/lib/factory/FormPayloadBuilder.ts
+++ b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/FormPayloadBuilder.ts
@@ -1,28 +1,30 @@
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
-import { WebhookTriggerEvents } from "@calcom/prisma/enums";
-import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../dto/types";
-import type { WebhookPayload } from "./types";
-
-export class FormPayloadBuilder {
- canHandle(triggerEvent: WebhookTriggerEvents): boolean {
- return (
- triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED ||
- triggerEvent === WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT
- );
- }
+import type { FormSubmittedDTO, FormSubmittedNoEventDTO } from "../../../dto/types";
+import type { WebhookPayload } from "../../types";
+import { BaseFormPayloadBuilder } from "../../base/BaseFormPayloadBuilder";
+/**
+ * Form payload builder for webhook version 2021-10-20.
+ *
+ * This is the initial form webhook payload format. It includes:
+ * - Form ID, name, and team ID
+ * - Structured responses object
+ * - Backwards compatibility: response values also at root level
+ */
+export class FormPayloadBuilder extends BaseFormPayloadBuilder {
+ /**
+ * Build the form webhook payload for v2021-10-20.
+ */
build(dto: FormSubmittedDTO | FormSubmittedNoEventDTO): WebhookPayload {
- // Type the responses properly using the routing-forms type
const responses = dto.response.data;
- // Create properly typed payload with primitive types
const payload: {
formId: string;
formName: string;
teamId: number | null;
responses: FORM_SUBMITTED_WEBHOOK_RESPONSES;
- [key: string]: unknown; // For backward compatibility fields
+ [key: string]: unknown;
} = {
formId: dto.form.id,
formName: dto.form.name,
@@ -31,25 +33,7 @@ export class FormPayloadBuilder {
};
// Add unwrapped response fields at root level for backwards compatibility
- // This ensures both `value` (deprecated) and `response` (new) are available
- Object.entries(responses).forEach(([fieldKey, fieldValue]) => {
- if (fieldValue && typeof fieldValue === "object") {
- // Each field should have both `value` (deprecated) and `response` (new)
- const responseField = fieldValue as {
- value?: unknown;
- response?: unknown;
- };
-
- // Add the field value directly to payload root for backward compatibility
- // This preserves the legacy behavior where field values were at root level
- if (responseField.value !== undefined) {
- payload[fieldKey] = responseField.value;
- } else if (responseField.response !== undefined) {
- // Fallback to response if value is not present
- payload[fieldKey] = responseField.response;
- }
- }
- });
+ this.addBackwardsCompatibilityFields(payload, responses);
return {
triggerEvent: dto.triggerEvent,
@@ -57,4 +41,28 @@ export class FormPayloadBuilder {
payload,
};
}
+
+ /**
+ * Add backwards compatibility fields to payload.
+ * This spreads individual response values at the root level of the payload.
+ */
+ private addBackwardsCompatibilityFields(
+ payload: Record,
+ responses: FORM_SUBMITTED_WEBHOOK_RESPONSES
+ ): void {
+ Object.entries(responses).forEach(([fieldKey, fieldValue]) => {
+ if (fieldValue && typeof fieldValue === "object") {
+ const responseField = fieldValue as {
+ value?: unknown;
+ response?: unknown;
+ };
+
+ if (responseField.value !== undefined) {
+ payload[fieldKey] = responseField.value;
+ } else if (responseField.response !== undefined) {
+ payload[fieldKey] = responseField.response;
+ }
+ }
+ });
+ }
}
diff --git a/packages/features/webhooks/lib/factory/versioned/v2021-10-20/InstantMeetingBuilder.ts b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/InstantMeetingBuilder.ts
new file mode 100644
index 0000000000..a3d7889c7a
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/InstantMeetingBuilder.ts
@@ -0,0 +1,30 @@
+import type { InstantMeetingDTO } from "../../../dto/types";
+import type { WebhookPayload } from "../../types";
+import { BaseInstantMeetingBuilder } from "../../base/BaseInstantMeetingBuilder";
+
+/**
+ * Instant meeting payload builder for webhook version v2021-10-20.
+ *
+ * This is the initial instant meeting webhook payload format.
+ * It includes notification-style data (title, body, icon, url, actions).
+ */
+export class InstantMeetingBuilder extends BaseInstantMeetingBuilder {
+ /**
+ * Build the instant meeting webhook payload for v2021-10-20.
+ */
+ build(dto: InstantMeetingDTO): WebhookPayload {
+ return {
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ payload: {
+ title: dto.title,
+ body: dto.body,
+ icon: dto.icon,
+ url: dto.url,
+ actions: dto.actions,
+ requireInteraction: dto.requireInteraction,
+ type: dto.type,
+ },
+ };
+ }
+}
diff --git a/packages/features/webhooks/lib/factory/versioned/v2021-10-20/MeetingPayloadBuilder.ts b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/MeetingPayloadBuilder.ts
new file mode 100644
index 0000000000..699af46af8
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/MeetingPayloadBuilder.ts
@@ -0,0 +1,50 @@
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type {
+ MeetingStartedDTO,
+ MeetingEndedDTO,
+ AfterHostsNoShowDTO,
+ AfterGuestsNoShowDTO,
+} from "../../../dto/types";
+import type { WebhookPayload } from "../../types";
+import { BaseMeetingPayloadBuilder } from "../../base/BaseMeetingPayloadBuilder";
+
+/**
+ * Meeting payload builder for webhook version v2021-10-20.
+ *
+ * Handles:
+ * - MEETING_STARTED / MEETING_ENDED: Full booking data in payload
+ * - AFTER_HOSTS_CAL_VIDEO_NO_SHOW / AFTER_GUESTS_CAL_VIDEO_NO_SHOW: bookingId and webhook info
+ */
+export class MeetingPayloadBuilder extends BaseMeetingPayloadBuilder {
+ /**
+ * Build the meeting webhook payload for v2021-10-20.
+ */
+ build(
+ dto: MeetingStartedDTO | MeetingEndedDTO | AfterHostsNoShowDTO | AfterGuestsNoShowDTO
+ ): WebhookPayload {
+ // Handle no-show events (different payload structure)
+ if (
+ dto.triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW ||
+ dto.triggerEvent === WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW
+ ) {
+ const noShowDto = dto as AfterHostsNoShowDTO | AfterGuestsNoShowDTO;
+ return {
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ payload: {
+ bookingId: noShowDto.bookingId,
+ webhook: noShowDto.webhook,
+ },
+ };
+ }
+
+ // Handle meeting started/ended events
+ const meetingDto = dto as MeetingStartedDTO | MeetingEndedDTO;
+ return {
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ payload: { ...meetingDto.booking },
+ };
+ }
+}
diff --git a/packages/features/webhooks/lib/factory/versioned/v2021-10-20/OOOPayloadBuilder.ts b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/OOOPayloadBuilder.ts
new file mode 100644
index 0000000000..6b8e63b250
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/OOOPayloadBuilder.ts
@@ -0,0 +1,22 @@
+import type { OOOCreatedDTO } from "../../../dto/types";
+import type { WebhookPayload } from "../../types";
+import { BaseOOOPayloadBuilder } from "../../base/BaseOOOPayloadBuilder";
+
+/**
+ * OOO (Out of Office) payload builder for webhook version 2021-10-20.
+ *
+ * This is the initial OOO webhook payload format.
+ * It includes the OOO entry data in the payload.
+ */
+export class OOOPayloadBuilder extends BaseOOOPayloadBuilder {
+ /**
+ * Build the OOO webhook payload for v2021-10-20.
+ */
+ build(dto: OOOCreatedDTO): WebhookPayload {
+ return {
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ payload: { oooEntry: dto.oooEntry },
+ };
+ }
+}
diff --git a/packages/features/webhooks/lib/factory/versioned/v2021-10-20/RecordingPayloadBuilder.ts b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/RecordingPayloadBuilder.ts
new file mode 100644
index 0000000000..dd70d03261
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/RecordingPayloadBuilder.ts
@@ -0,0 +1,46 @@
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { RecordingReadyDTO, TranscriptionGeneratedDTO } from "../../../dto/types";
+import type { WebhookPayload } from "../../types";
+import { BaseRecordingPayloadBuilder } from "../../base/BaseRecordingPayloadBuilder";
+
+/**
+ * Recording payload builder for webhook version 2021-10-20.
+ *
+ * This is the initial recording webhook payload format.
+ * It handles both RECORDING_READY and RECORDING_TRANSCRIPTION_GENERATED events.
+ */
+export class RecordingPayloadBuilder extends BaseRecordingPayloadBuilder {
+ /**
+ * Build the recording webhook payload for v2021-10-20.
+ */
+ build(dto: RecordingReadyDTO | TranscriptionGeneratedDTO): WebhookPayload {
+ if (dto.triggerEvent === WebhookTriggerEvents.RECORDING_READY) {
+ return this.buildRecordingReadyPayload(dto as RecordingReadyDTO);
+ }
+
+ return this.buildTranscriptionPayload(dto as TranscriptionGeneratedDTO);
+ }
+
+ /**
+ * Build recording ready payload for v2021-10-20.
+ */
+ private buildRecordingReadyPayload(dto: RecordingReadyDTO): WebhookPayload {
+ return {
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ payload: { downloadLink: dto.downloadLink },
+ };
+ }
+
+ /**
+ * Build transcription generated payload for v2021-10-20.
+ */
+ private buildTranscriptionPayload(dto: TranscriptionGeneratedDTO): WebhookPayload {
+ return {
+ triggerEvent: dto.triggerEvent,
+ createdAt: dto.createdAt,
+ payload: { downloadLinks: dto.downloadLinks },
+ };
+ }
+}
diff --git a/packages/features/webhooks/lib/factory/versioned/v2021-10-20/index.ts b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/index.ts
new file mode 100644
index 0000000000..b6eaff645e
--- /dev/null
+++ b/packages/features/webhooks/lib/factory/versioned/v2021-10-20/index.ts
@@ -0,0 +1,15 @@
+/**
+ * Webhook Payload Builders for Version v2021-10-20
+ *
+ * This is the initial webhook version. All builders extend base classes
+ * and use default implementations.
+ *
+ */
+
+export { BookingPayloadBuilder } from "./BookingPayloadBuilder";
+export { FormPayloadBuilder } from "./FormPayloadBuilder";
+export { MeetingPayloadBuilder } from "./MeetingPayloadBuilder";
+export { RecordingPayloadBuilder } from "./RecordingPayloadBuilder";
+export { OOOPayloadBuilder } from "./OOOPayloadBuilder";
+export { InstantMeetingBuilder } from "./InstantMeetingBuilder";
+export { DelegationPayloadBuilder } from "./DelegationPayloadBuilder";
diff --git a/packages/features/webhooks/lib/getWebhooks.ts b/packages/features/webhooks/lib/getWebhooks.ts
index 4628d0946a..c46efe2f0c 100644
--- a/packages/features/webhooks/lib/getWebhooks.ts
+++ b/packages/features/webhooks/lib/getWebhooks.ts
@@ -1,9 +1,11 @@
import { withReporting } from "@calcom/lib/sentryWrapper";
import defaultPrisma from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
-import type { Prisma } from "@calcom/prisma/client";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
+import type { WebhookSubscriber } from "./dto/types";
+import { WebhookOutputMapper } from "./infrastructure/mappers/WebhookOutputMapper";
+
export type GetSubscriberOptions = {
userId?: number | null;
eventTypeId?: number | null;
@@ -13,25 +15,11 @@ export type GetSubscriberOptions = {
oAuthClientId?: string | null;
};
-const webhookSelect = {
- id: true,
- subscriberUrl: true,
- payloadTemplate: true,
- appId: true,
- secret: true,
- time: true,
- timeUnit: true,
- eventTriggers: true,
-} satisfies Prisma.WebhookSelect;
-
-export type GetWebhooksReturnType = Prisma.WebhookGetPayload<{
- select: typeof webhookSelect;
-}>[];
const getWebhooks = async (
options: GetSubscriberOptions,
prisma: PrismaClient = defaultPrisma
-): Promise => {
+): Promise => {
const teamId = options.teamId;
const userId = options.userId ?? 0;
const eventTypeId = options.eventTypeId ?? 0;
@@ -85,10 +73,20 @@ const getWebhooks = async (
},
},
},
- select: webhookSelect,
+ select: {
+ id: true,
+ subscriberUrl: true,
+ payloadTemplate: true,
+ appId: true,
+ secret: true,
+ time: true,
+ timeUnit: true,
+ eventTriggers: true,
+ version: true,
+ },
});
- return allWebhooks;
+ return allWebhooks.map(WebhookOutputMapper.toSubscriberPartial);
};
export default withReporting(getWebhooks, "getWebhooks");
diff --git a/packages/features/webhooks/lib/infrastructure/mappers/WebhookOutputMapper.ts b/packages/features/webhooks/lib/infrastructure/mappers/WebhookOutputMapper.ts
new file mode 100644
index 0000000000..c36f5288cb
--- /dev/null
+++ b/packages/features/webhooks/lib/infrastructure/mappers/WebhookOutputMapper.ts
@@ -0,0 +1,134 @@
+import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { Webhook, WebhookSubscriber } from "../../dto/types";
+import { parseWebhookVersion } from "../../interface/IWebhookRepository";
+
+/**
+ * Full webhook from Prisma (for CRUD operations)
+ */
+type PrismaWebhookFull = {
+ id: string;
+ subscriberUrl: string;
+ payloadTemplate: string | null;
+ appId: string | null;
+ secret: string | null;
+ active: boolean;
+ eventTriggers: WebhookTriggerEvents[];
+ eventTypeId: number | null;
+ teamId: number | null;
+ userId: number | null;
+ time: number | null;
+ timeUnit: TimeUnit | null;
+ version: string;
+ createdAt: Date;
+ platform: boolean;
+ platformOAuthClientId: string | null;
+};
+
+/**
+ * Minimal webhook from Prisma (for delivery/subscribers)
+ */
+type PrismaWebhookMinimal = {
+ id: string;
+ subscriberUrl: string;
+ payloadTemplate: string | null;
+ appId: string | null;
+ secret: string | null;
+ eventTriggers: WebhookTriggerEvents[];
+ time: number | null;
+ timeUnit: TimeUnit | null;
+ version: string;
+};
+
+export class WebhookOutputMapper {
+ /**
+ * Map full Prisma webhook to domain Webhook (for UI/CRUD)
+ */
+ static toWebhook(prismaWebhook: PrismaWebhookFull): Webhook {
+ return {
+ id: prismaWebhook.id,
+ subscriberUrl: prismaWebhook.subscriberUrl,
+ payloadTemplate: prismaWebhook.payloadTemplate,
+ appId: prismaWebhook.appId,
+ secret: prismaWebhook.secret,
+ active: prismaWebhook.active,
+ eventTriggers: prismaWebhook.eventTriggers,
+ eventTypeId: prismaWebhook.eventTypeId,
+ teamId: prismaWebhook.teamId,
+ userId: prismaWebhook.userId,
+ time: prismaWebhook.time,
+ timeUnit: prismaWebhook.timeUnit,
+ version: parseWebhookVersion(prismaWebhook.version),
+ createdAt: prismaWebhook.createdAt,
+ platform: prismaWebhook.platform,
+ platformOAuthClientId: prismaWebhook.platformOAuthClientId,
+ };
+ }
+
+ static toWebhookList(prismaWebhooks: PrismaWebhookFull[]): Webhook[] {
+ return prismaWebhooks.map(WebhookOutputMapper.toWebhook);
+ }
+
+ /**
+ * Map minimal Prisma webhook to WebhookSubscriber (for delivery)
+ */
+ static toSubscriber(prismaWebhook: PrismaWebhookMinimal): WebhookSubscriber {
+ return {
+ id: prismaWebhook.id,
+ subscriberUrl: prismaWebhook.subscriberUrl,
+ payloadTemplate: prismaWebhook.payloadTemplate,
+ appId: prismaWebhook.appId,
+ secret: prismaWebhook.secret,
+ eventTriggers: prismaWebhook.eventTriggers,
+ time: prismaWebhook.time ?? undefined,
+ timeUnit: prismaWebhook.timeUnit ?? undefined,
+ version: parseWebhookVersion(prismaWebhook.version),
+ };
+ }
+
+ static toSubscriberList(prismaWebhooks: PrismaWebhookMinimal[]): WebhookSubscriber[] {
+ return prismaWebhooks.map(WebhookOutputMapper.toSubscriber);
+ }
+
+ /**
+ * Legacy method - kept for backward compatibility
+ * @deprecated Use toWebhook() or toSubscriber() based on context
+ */
+ static toDomain(prismaWebhook: PrismaWebhookMinimal): WebhookSubscriber {
+ return WebhookOutputMapper.toSubscriber(prismaWebhook);
+ }
+
+ /**
+ * Legacy method - kept for backward compatibility
+ * @deprecated Use toWebhookList() or toSubscriberList() based on context
+ */
+ static toDomainList(prismaWebhooks: PrismaWebhookMinimal[]): WebhookSubscriber[] {
+ return WebhookOutputMapper.toSubscriberList(prismaWebhooks);
+ }
+
+ static toSubscriberPartial(prismaWebhook: {
+ id: string;
+ subscriberUrl: string;
+ eventTriggers: string[];
+ version: string;
+ payloadTemplate?: string | null;
+ appId?: string | null;
+ secret?: string | null;
+ time?: number | null;
+ timeUnit?: string | null;
+ }): WebhookSubscriber {
+ return {
+ id: prismaWebhook.id,
+ subscriberUrl: prismaWebhook.subscriberUrl,
+ payloadTemplate: prismaWebhook.payloadTemplate ?? null,
+ appId: prismaWebhook.appId ?? null,
+ secret: prismaWebhook.secret ?? null,
+ // Enums are just strings at runtime - Prisma validates them
+ eventTriggers: prismaWebhook.eventTriggers as unknown as WebhookSubscriber["eventTriggers"],
+ time: prismaWebhook.time ?? undefined,
+ timeUnit: prismaWebhook.timeUnit as unknown as WebhookSubscriber["timeUnit"],
+ version: parseWebhookVersion(prismaWebhook.version),
+ };
+ }
+}
+
diff --git a/packages/features/webhooks/lib/interface/IWebhookRepository.ts b/packages/features/webhooks/lib/interface/IWebhookRepository.ts
new file mode 100644
index 0000000000..d3c70c8e56
--- /dev/null
+++ b/packages/features/webhooks/lib/interface/IWebhookRepository.ts
@@ -0,0 +1,87 @@
+import type { WebhookTriggerEvents, UserPermissionRole } from "@calcom/prisma/enums";
+
+import type { Webhook, WebhookSubscriber, WebhookGroup } from "../dto/types";
+
+/**
+ * Webhook Version enum - defines the payload format versions.
+ *
+ * This is a TypeScript-only enum (not Prisma).
+ * DB operations go through the repository which enforces these values.
+ */
+export const WebhookVersion = {
+ V_2021_10_20: "2021-10-20",
+} as const;
+
+export type WebhookVersion = (typeof WebhookVersion)[keyof typeof WebhookVersion];
+
+/**
+ * Default webhook version - used for new webhooks and as fallback
+ */
+export const DEFAULT_WEBHOOK_VERSION = WebhookVersion.V_2021_10_20;
+
+const VALID_WEBHOOK_VERSIONS = new Set(Object.values(WebhookVersion));
+
+
+export function isValidWebhookVersion(value: string): value is WebhookVersion {
+ return VALID_WEBHOOK_VERSIONS.has(value);
+}
+
+/**
+ * Parse and validate a webhook version string.
+ * Throws if the version is invalid.
+ */
+export function parseWebhookVersion(value: string): WebhookVersion {
+ if (!isValidWebhookVersion(value)) {
+ throw new Error(
+ `Invalid webhook version: "${value}". Valid versions are: ${Object.values(WebhookVersion).join(", ")}`
+ );
+ }
+ return value;
+}
+export interface GetSubscribersOptions {
+ userId?: number | null;
+ eventTypeId?: number | null;
+ triggerEvent: WebhookTriggerEvents;
+ teamId?: number | number[] | null;
+ orgId?: number | null;
+ oAuthClientId?: string | null;
+}
+
+export interface ListWebhooksOptions {
+ userId: number;
+ appId?: string | null;
+ eventTypeId?: number | null;
+ eventTriggers?: WebhookTriggerEvents[];
+}
+
+export interface IWebhookRepository {
+ getSubscribers(options: GetSubscribersOptions): Promise;
+ getWebhookById(id: string): Promise;
+ findByWebhookId(webhookId?: string): Promise<{
+ id: string;
+ subscriberUrl: string;
+ payloadTemplate: string | null;
+ active: boolean;
+ eventTriggers: WebhookTriggerEvents[];
+ secret: string | null;
+ teamId: number | null;
+ userId: number | null;
+ platform: boolean;
+ time: number | null;
+ timeUnit: string | null;
+ version: WebhookVersion;
+ }>;
+ getFilteredWebhooksForUser(options: { userId: number; userRole?: UserPermissionRole }): Promise<{
+ webhookGroups: WebhookGroup[];
+ profiles: {
+ teamId: number | null | undefined;
+ slug: string | null;
+ name: string | null;
+ image?: string | undefined;
+ canModify?: boolean;
+ canDelete?: boolean;
+ }[];
+ }>;
+ listWebhooks(options: ListWebhooksOptions): Promise;
+}
+
diff --git a/packages/features/webhooks/lib/interface/repository.ts b/packages/features/webhooks/lib/interface/repository.ts
deleted file mode 100644
index 6a4792147c..0000000000
--- a/packages/features/webhooks/lib/interface/repository.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { Webhook } from "@calcom/prisma/client";
-import type { WebhookTriggerEvents, UserPermissionRole } from "@calcom/prisma/enums";
-
-import type { WebhookSubscriber } from "../dto/types";
-
-export interface GetSubscribersOptions {
- userId?: number | null;
- eventTypeId?: number | null;
- triggerEvent: WebhookTriggerEvents;
- teamId?: number | number[] | null;
- orgId?: number | null;
- oAuthClientId?: string | null;
-}
-
-type WebhookGroup = {
- teamId?: number | null;
- profile: {
- slug: string | null;
- name: string | null;
- image?: string;
- };
- metadata?: {
- canModify: boolean;
- canDelete: boolean;
- };
- webhooks: Webhook[];
-};
-
-export interface IWebhookRepository {
- getSubscribers(options: GetSubscribersOptions): Promise;
- getWebhookById(id: string): Promise;
- findByWebhookId(webhookId?: string): Promise<{
- id: string;
- subscriberUrl: string;
- payloadTemplate: string | null;
- active: boolean;
- eventTriggers: WebhookTriggerEvents[];
- secret: string | null;
- teamId: number | null;
- userId: number | null;
- platform: boolean;
- time: number | null;
- timeUnit: string | null;
- }>;
- getFilteredWebhooksForUser(options: { userId: number; userRole?: UserPermissionRole }): Promise<{
- webhookGroups: WebhookGroup[];
- profiles: {
- teamId: number | null | undefined;
- slug: string | null;
- name: string | null;
- image?: string | undefined;
- canModify?: boolean;
- canDelete?: boolean;
- }[];
- }>;
-}
diff --git a/packages/features/webhooks/lib/repository/WebhookRepository.ts b/packages/features/webhooks/lib/repository/WebhookRepository.ts
index b24be4bd0f..4e472c88a6 100644
--- a/packages/features/webhooks/lib/repository/WebhookRepository.ts
+++ b/packages/features/webhooks/lib/repository/WebhookRepository.ts
@@ -4,12 +4,15 @@ import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { withReporting } from "@calcom/lib/sentryWrapper";
import { prisma as defaultPrisma } from "@calcom/prisma";
import type { PrismaClient } from "@calcom/prisma";
-import type { Webhook } from "@calcom/prisma/client";
+import type { Prisma } from "@calcom/prisma/client";
import type { TimeUnit, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { UserPermissionRole, MembershipRole } from "@calcom/prisma/enums";
-import type { WebhookSubscriber } from "../dto/types";
-import type { IWebhookRepository } from "../interface/repository";
+import { parseWebhookVersion } from "../interface/IWebhookRepository";
+
+import type { Webhook, WebhookSubscriber, WebhookGroup } from "../dto/types";
+import type { IWebhookRepository, WebhookVersion, ListWebhooksOptions } from "../interface/IWebhookRepository";
+import { WebhookOutputMapper } from "../infrastructure/mappers/WebhookOutputMapper";
import type { GetSubscribersOptions } from "./types";
// Type for raw query results from the database
@@ -22,24 +25,13 @@ interface WebhookQueryResult {
time: number | null;
timeUnit: TimeUnit | null;
eventTriggers: WebhookTriggerEvents[];
+ version: WebhookVersion;
priority: number; // This field is added by the query and removed before returning
}
-type WebhookGroup = {
- teamId?: number | null;
- profile: {
- slug: string | null;
- name: string | null;
- image?: string;
- };
- metadata?: {
- canModify: boolean;
- canDelete: boolean;
- };
- webhooks: Webhook[];
-};
-const filterWebhooks = (webhook: Webhook) => {
+
+const filterWebhooks = (webhook: { appId: string | null }) => {
const appIds = [
"zapier",
"make",
@@ -103,6 +95,7 @@ export class WebhookRepository implements IWebhookRepository {
time: webhook.time,
timeUnit: webhook.timeUnit as TimeUnit | null,
eventTriggers: webhook.eventTriggers as WebhookTriggerEvents[],
+ version: webhook.version,
}));
}
@@ -124,7 +117,7 @@ export class WebhookRepository implements IWebhookRepository {
const results = await this.prisma.$queryRaw`
-- Platform webhooks (highest priority)
SELECT
- id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
+ id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
1 as priority
FROM "Webhook"
WHERE active = true
@@ -135,7 +128,7 @@ export class WebhookRepository implements IWebhookRepository {
-- User-specific webhooks (only if userId provided)
SELECT
- id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
+ id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
2 as priority
FROM "Webhook"
WHERE active = true
@@ -148,7 +141,7 @@ export class WebhookRepository implements IWebhookRepository {
-- Event type webhooks (only if eventTypeId provided)
SELECT
- id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
+ id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
3 as priority
FROM "Webhook"
WHERE active = true
@@ -161,7 +154,7 @@ export class WebhookRepository implements IWebhookRepository {
-- Parent event type webhooks (only if managedParentEventTypeId provided)
SELECT
- id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
+ id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
4 as priority
FROM "Webhook"
WHERE active = true
@@ -174,7 +167,7 @@ export class WebhookRepository implements IWebhookRepository {
-- Team webhooks (only if teamIds provided and not empty)
SELECT
- id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
+ id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
5 as priority
FROM "Webhook"
WHERE active = true
@@ -188,7 +181,7 @@ export class WebhookRepository implements IWebhookRepository {
-- OAuth client webhooks (only if oAuthClientId provided)
SELECT
- id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers",
+ id, "subscriberUrl", "payloadTemplate", "appId", secret, time, "timeUnit", "eventTriggers", version,
6 as priority
FROM "Webhook"
WHERE active = true
@@ -223,6 +216,7 @@ export class WebhookRepository implements IWebhookRepository {
time: true,
timeUnit: true,
eventTriggers: true,
+ version: true,
},
});
@@ -237,11 +231,12 @@ export class WebhookRepository implements IWebhookRepository {
time: webhook.time,
timeUnit: webhook.timeUnit as TimeUnit | null,
eventTriggers: webhook.eventTriggers,
+ version: parseWebhookVersion(webhook.version),
};
}
async findByWebhookId(webhookId?: string) {
- return await this.prisma.webhook.findUniqueOrThrow({
+ const webhook = await this.prisma.webhook.findUniqueOrThrow({
where: {
id: webhookId,
},
@@ -257,8 +252,14 @@ export class WebhookRepository implements IWebhookRepository {
platform: true,
time: true,
timeUnit: true,
+ version: true,
},
});
+
+ return {
+ ...webhook,
+ version: parseWebhookVersion(webhook.version),
+ };
}
async findByOrgIdAndTrigger({
@@ -268,7 +269,7 @@ export class WebhookRepository implements IWebhookRepository {
orgId: number;
triggerEvent: WebhookTriggerEvents;
}): Promise {
- return await this.prisma.webhook.findMany({
+ const webhooks = await this.prisma.webhook.findMany({
where: {
teamId: orgId,
platform: false,
@@ -287,8 +288,14 @@ export class WebhookRepository implements IWebhookRepository {
time: true,
timeUnit: true,
appId: true,
+ version: true,
},
});
+ return webhooks.map((webhook) => ({
+ ...webhook,
+ eventTriggers: webhook.eventTriggers as WebhookTriggerEvents[],
+ version: parseWebhookVersion(webhook.version),
+ }));
}
async getFilteredWebhooksForUser({ userId, userRole }: { userId: number; userRole?: UserPermissionRole }) {
@@ -299,7 +306,26 @@ export class WebhookRepository implements IWebhookRepository {
username: true,
name: true,
avatarUrl: true,
- webhooks: true,
+ webhooks: {
+ select: {
+ id: true,
+ subscriberUrl: true,
+ payloadTemplate: true,
+ appId: true,
+ secret: true,
+ active: true,
+ eventTriggers: true,
+ eventTypeId: true,
+ teamId: true,
+ userId: true,
+ time: true,
+ timeUnit: true,
+ version: true,
+ createdAt: true,
+ platform: true,
+ platformOAuthClientId: true,
+ },
+ },
teams: {
where: {
accepted: true,
@@ -312,7 +338,26 @@ export class WebhookRepository implements IWebhookRepository {
name: true,
slug: true,
logoUrl: true,
- webhooks: true,
+ webhooks: {
+ select: {
+ id: true,
+ subscriberUrl: true,
+ payloadTemplate: true,
+ appId: true,
+ secret: true,
+ active: true,
+ eventTriggers: true,
+ eventTypeId: true,
+ teamId: true,
+ userId: true,
+ time: true,
+ timeUnit: true,
+ version: true,
+ createdAt: true,
+ platform: true,
+ platformOAuthClientId: true,
+ },
+ },
},
},
},
@@ -338,7 +383,7 @@ export class WebhookRepository implements IWebhookRepository {
name: user.name,
image: getUserAvatarUrl({ avatarUrl: user.avatarUrl }),
},
- webhooks: user.webhooks.filter(filterWebhooks),
+ webhooks: WebhookOutputMapper.toWebhookList(user.webhooks.filter(filterWebhooks)),
metadata: {
canModify: true,
canDelete: true,
@@ -386,7 +431,7 @@ export class WebhookRepository implements IWebhookRepository {
slug: membership.team.slug || null,
image: getPlaceholderAvatar(membership.team.logoUrl, membership.team.name),
},
- webhooks: membership.team.webhooks.filter(filterWebhooks),
+ webhooks: WebhookOutputMapper.toWebhookList(membership.team.webhooks.filter(filterWebhooks)),
metadata: {
canModify: canUpdate,
canDelete,
@@ -398,6 +443,24 @@ export class WebhookRepository implements IWebhookRepository {
if (userRole === UserPermissionRole.ADMIN) {
const platformWebhooks = await this.prisma.webhook.findMany({
where: { platform: true },
+ select: {
+ id: true,
+ subscriberUrl: true,
+ payloadTemplate: true,
+ appId: true,
+ secret: true,
+ active: true,
+ eventTriggers: true,
+ eventTypeId: true,
+ teamId: true,
+ userId: true,
+ time: true,
+ timeUnit: true,
+ version: true,
+ createdAt: true,
+ platform: true,
+ platformOAuthClientId: true,
+ },
});
webhookGroups.push({
@@ -407,7 +470,7 @@ export class WebhookRepository implements IWebhookRepository {
name: "Platform",
image: getPlaceholderAvatar(null, "Platform"),
},
- webhooks: platformWebhooks,
+ webhooks: WebhookOutputMapper.toWebhookList(platformWebhooks),
metadata: {
canDelete: true,
canModify: true,
@@ -424,6 +487,101 @@ export class WebhookRepository implements IWebhookRepository {
})),
};
}
+
+ /**
+ * List webhooks for a user with filtering options.
+ * Handles:
+ * - App filtering (excludes zapier/make by default unless appId specified)
+ * - Event type filtering (with managed event type parent handling)
+ * - Event trigger filtering
+ * - Permission-based team filtering
+ */
+ async listWebhooks(options: ListWebhooksOptions): Promise {
+ const { userId, appId, eventTypeId, eventTriggers } = options;
+
+ // Build WHERE conditions
+ const whereConditions: NonNullable = [
+ // AppId filter - null appId by default (excludes zapier/make)
+ { appId: appId ?? null },
+ ];
+
+ // Get user's teams
+ const user = await this.prisma.user.findUnique({
+ where: { id: userId },
+ select: { teams: true },
+ });
+
+ if (eventTypeId) {
+ // Check for managed event type parent
+ const managedParentEvt = await this.prisma.eventType.findFirst({
+ where: {
+ id: eventTypeId,
+ parentId: { not: null },
+ },
+ select: { parentId: true },
+ });
+
+ if (managedParentEvt?.parentId) {
+ // Include webhooks from both the event type and its parent (if active)
+ whereConditions.push({
+ OR: [{ eventTypeId }, { eventTypeId: managedParentEvt.parentId, active: true }],
+ });
+ } else {
+ whereConditions.push({ eventTypeId });
+ }
+ } else {
+ // No eventTypeId - filter by user and their allowed teams
+ const permissionService = new PermissionCheckService();
+ const teamIds = user?.teams?.map((m) => m.teamId) ?? [];
+
+ const allowedTeamIds = (
+ await Promise.all(
+ teamIds.map(async (teamId) => {
+ const ok = await permissionService.checkPermission({
+ userId,
+ teamId,
+ permission: "webhook.read",
+ fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
+ });
+ return ok ? teamId : null;
+ })
+ )
+ ).filter((x): x is number => x !== null);
+
+ whereConditions.push({
+ OR: [{ userId }, ...(allowedTeamIds.length ? [{ teamId: { in: allowedTeamIds } }] : [])],
+ });
+ }
+
+ // Event triggers filter
+ if (eventTriggers?.length) {
+ whereConditions.push({ eventTriggers: { hasEvery: eventTriggers } });
+ }
+
+ const webhooks = await this.prisma.webhook.findMany({
+ where: { AND: whereConditions },
+ select: {
+ id: true,
+ subscriberUrl: true,
+ payloadTemplate: true,
+ appId: true,
+ secret: true,
+ active: true,
+ eventTriggers: true,
+ eventTypeId: true,
+ teamId: true,
+ userId: true,
+ time: true,
+ timeUnit: true,
+ version: true,
+ createdAt: true,
+ platform: true,
+ platformOAuthClientId: true,
+ },
+ });
+
+ return WebhookOutputMapper.toWebhookList(webhooks);
+ }
}
export const webhookRepository = withReporting(
diff --git a/packages/features/webhooks/lib/sendPayload.ts b/packages/features/webhooks/lib/sendPayload.ts
index d8ee69a521..7dc096df52 100644
--- a/packages/features/webhooks/lib/sendPayload.ts
+++ b/packages/features/webhooks/lib/sendPayload.ts
@@ -3,11 +3,14 @@ import { compile } from "handlebars";
import type { TGetTranscriptAccessLink } from "@calcom/app-store/dailyvideo/zod";
import { getHumanReadableLocationValue } from "@calcom/app-store/locations";
+import type { WebhookSubscriber, PaymentData } from "@calcom/features/webhooks/lib/dto/types";
import { DelegationCredentialErrorPayloadType } from "@calcom/features/webhooks/lib/dto/types";
import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
-import type { Payment, Webhook } from "@calcom/prisma/client";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
+// Minimal webhook shape for sending payloads (subset of WebhookSubscriber)
+type WebhookForPayload = Pick;
+
type ContentType = "application/json" | "application/x-www-form-urlencoded";
export type EventTypeInfo = {
@@ -92,7 +95,7 @@ export type EventPayloadType = CalendarEvent &
paymentId?: number;
rescheduledBy?: string;
cancelledBy?: string;
- paymentData?: Payment;
+ paymentData?: PaymentData;
};
export type WebhookPayloadType =
@@ -217,7 +220,7 @@ const sendPayload = async (
secretKey: string | null,
triggerEvent: string,
createdAt: string,
- webhook: Pick,
+ webhook: WebhookForPayload,
data: WebhookPayloadType
) => {
const { appId, payloadTemplate: template } = webhook;
@@ -268,7 +271,7 @@ export const sendGenericWebhookPayload = async ({
secretKey: string | null;
triggerEvent: string;
createdAt: string;
- webhook: Pick;
+ webhook: WebhookForPayload;
data: Record;
rootData?: Record;
}) => {
@@ -302,7 +305,7 @@ export const createWebhookSignature = (params: { secret?: string | null; body: s
const _sendPayload = async (
secretKey: string | null,
- webhook: Pick,
+ webhook: WebhookForPayload,
body: string,
contentType: "application/json" | "application/x-www-form-urlencoded"
) => {
diff --git a/packages/features/webhooks/lib/service/WebhookNotificationHandler.test.ts b/packages/features/webhooks/lib/service/WebhookNotificationHandler.test.ts
new file mode 100644
index 0000000000..6d0342b885
--- /dev/null
+++ b/packages/features/webhooks/lib/service/WebhookNotificationHandler.test.ts
@@ -0,0 +1,309 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { BookingWebhookEventDTO, WebhookEventDTO, WebhookSubscriber } from "../dto/types";
+import { WebhookVersion } from "../interface/IWebhookRepository";
+import type { PayloadBuilderFactory } from "../factory/versioned/PayloadBuilderFactory";
+import type { ILogger } from "../interface/infrastructure";
+import type { IWebhookService } from "../interface/services";
+import { WebhookNotificationHandler } from "./WebhookNotificationHandler";
+
+describe("WebhookNotificationHandler", () => {
+ let handler: WebhookNotificationHandler;
+ let mockWebhookService: IWebhookService;
+ let mockFactory: PayloadBuilderFactory;
+ let mockLogger: ILogger;
+
+ const mockSubscribers: WebhookSubscriber[] = [
+ {
+ id: "webhook-1",
+ subscriberUrl: "https://example.com/webhook",
+ payloadTemplate: null,
+ appId: null,
+ secret: "secret123",
+ eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
+ time: null,
+ timeUnit: null,
+ version: WebhookVersion.V_2021_10_20,
+ },
+ {
+ id: "webhook-2",
+ subscriberUrl: "https://example2.com/webhook",
+ payloadTemplate: null,
+ appId: null,
+ secret: "secret456",
+ eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
+ time: null,
+ timeUnit: null,
+ version: WebhookVersion.V_2021_10_20,
+ },
+ ];
+
+ beforeEach(() => {
+ // Mock webhook service
+ mockWebhookService = {
+ getSubscribers: vi.fn().mockResolvedValue(mockSubscribers),
+ processWebhooks: vi.fn().mockResolvedValue(undefined),
+ sendWebhook: vi.fn(),
+ scheduleWebhook: vi.fn(),
+ sendWebhookDirectly: vi.fn(),
+ };
+
+ // Mock factory - returns appropriate payload based on trigger event
+ mockFactory = {
+ getBuilder: vi.fn().mockImplementation((_version: string, triggerEvent: WebhookTriggerEvents) => ({
+ build: vi.fn().mockImplementation((dto: WebhookEventDTO) => ({
+ triggerEvent,
+ createdAt: dto.createdAt,
+ payload: dto,
+ })),
+ })),
+ registerVersion: vi.fn(),
+ getRegisteredVersions: vi.fn().mockReturnValue([WebhookVersion.V_2021_10_20]),
+ };
+
+ // Mock logger
+ mockLogger = {
+ getSubLogger: vi.fn().mockReturnThis(),
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+
+ handler = new WebhookNotificationHandler(mockWebhookService, mockFactory, mockLogger);
+ });
+
+ describe("Constructor", () => {
+ it("should initialize with dependencies", () => {
+ expect(handler).toBeInstanceOf(WebhookNotificationHandler);
+ });
+
+ it("should create sublogger", () => {
+ expect(mockLogger.getSubLogger).toHaveBeenCalledWith({
+ prefix: ["[WebhookNotificationHandler]"],
+ });
+ });
+ });
+
+ describe("handleNotification", () => {
+ const mockDTO: BookingWebhookEventDTO = {
+ triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
+ createdAt: "2024-01-15T10:00:00Z",
+ bookingId: 1,
+ eventTypeId: 1,
+ userId: 1,
+ teamId: null,
+ orgId: null,
+ platformClientId: null,
+ booking: {
+ id: 1,
+ eventTypeId: 1,
+ userId: 1,
+ smsReminderNumber: null,
+ },
+ eventType: {
+ eventTitle: "Test Event",
+ eventDescription: "Test Description",
+ requiresConfirmation: false,
+ price: 0,
+ currency: "USD",
+ length: 30,
+ },
+ evt: {
+ type: "test-event",
+ title: "Test Meeting",
+ description: "Meeting description",
+ startTime: "2024-01-15T10:00:00Z",
+ endTime: "2024-01-15T10:30:00Z",
+ organizer: {
+ id: 1,
+ email: "organizer@test.com",
+ name: "Test Organizer",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ attendees: [
+ {
+ email: "attendee@test.com",
+ name: "Test Attendee",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ ],
+ uid: "booking-uid-123",
+ customInputs: {},
+ responses: {},
+ userFieldsResponses: {},
+ },
+ };
+
+ it("should query for subscribers with correct params", async () => {
+ await handler.handleNotification(mockDTO);
+
+ expect(mockWebhookService.getSubscribers).toHaveBeenCalledWith({
+ userId: 1,
+ eventTypeId: 1,
+ triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
+ teamId: null,
+ orgId: null,
+ oAuthClientId: null,
+ });
+ });
+
+ it("should use factory to build payload", async () => {
+ await handler.handleNotification(mockDTO);
+
+ expect(mockFactory.getBuilder).toHaveBeenCalledWith(WebhookVersion.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
+ });
+
+ it("should process webhooks with built payload", async () => {
+ await handler.handleNotification(mockDTO);
+
+ expect(mockWebhookService.processWebhooks).toHaveBeenCalledWith(
+ WebhookTriggerEvents.BOOKING_CREATED,
+ expect.objectContaining({
+ triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
+ payload: expect.any(Object),
+ }),
+ mockSubscribers
+ );
+ });
+
+ it("should log debug messages", async () => {
+ await handler.handleNotification(mockDTO);
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "Querying for webhook subscribers with params:",
+ expect.any(Object)
+ );
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ expect.stringContaining("Successfully processed webhook notification"),
+ expect.any(Object)
+ );
+ });
+
+ it("should return early when no subscribers found", async () => {
+ mockWebhookService.getSubscribers.mockResolvedValue([]);
+
+ await handler.handleNotification(mockDTO);
+
+ expect(mockWebhookService.processWebhooks).not.toHaveBeenCalled();
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ expect.stringContaining("No subscribers found"),
+ expect.any(Object)
+ );
+ });
+
+ it("should handle errors gracefully", async () => {
+ const error = new Error("Test error");
+ mockWebhookService.getSubscribers.mockRejectedValue(error);
+
+ await expect(handler.handleNotification(mockDTO)).rejects.toThrow("Test error");
+
+ expect(mockLogger.error).toHaveBeenCalledWith(
+ expect.stringContaining("Error handling webhook notification"),
+ expect.objectContaining({
+ error: "Test error",
+ })
+ );
+ });
+
+ it("should support dry run mode", async () => {
+ await handler.handleNotification(mockDTO, true);
+
+ expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining("Dry run mode"));
+ expect(mockWebhookService.getSubscribers).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("Special Event Handling", () => {
+ it("should handle AFTER_HOSTS_CAL_VIDEO_NO_SHOW through factory", async () => {
+ const noShowDTO: WebhookEventDTO = {
+ triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
+ createdAt: "2024-01-15T10:00:00Z",
+ bookingId: 1,
+ webhook: { id: "webhook-1" },
+ };
+
+ mockWebhookService.getSubscribers.mockResolvedValue(mockSubscribers);
+
+ await handler.handleNotification(noShowDTO);
+
+ // All events now go through the factory for consistent versioning
+ expect(mockFactory.getBuilder).toHaveBeenCalledWith(
+ WebhookVersion.V_2021_10_20,
+ WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW
+ );
+ expect(mockWebhookService.processWebhooks).toHaveBeenCalledWith(
+ WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
+ {
+ triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
+ createdAt: noShowDTO.createdAt,
+ payload: noShowDTO,
+ },
+ mockSubscribers
+ );
+ });
+
+ it("should handle DELEGATION_CREDENTIAL_ERROR through factory", async () => {
+ const errorDTO: WebhookEventDTO = {
+ triggerEvent: WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR,
+ createdAt: "2024-01-15T10:00:00Z",
+ error: "Credential error",
+ credential: { id: 1, type: "test" },
+ user: { id: 1, email: "user@test.com" },
+ };
+
+ mockWebhookService.getSubscribers.mockResolvedValue(mockSubscribers);
+
+ await handler.handleNotification(errorDTO);
+
+ // All events now go through the factory for consistent versioning
+ expect(mockFactory.getBuilder).toHaveBeenCalledWith(
+ WebhookVersion.V_2021_10_20,
+ WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR
+ );
+ expect(mockWebhookService.processWebhooks).toHaveBeenCalledWith(
+ WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR,
+ {
+ triggerEvent: WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR,
+ createdAt: errorDTO.createdAt,
+ payload: errorDTO,
+ },
+ mockSubscribers
+ );
+ });
+ });
+
+ describe("Version Handling", () => {
+ it("should use default version (v2021-10-20) for all events currently", async () => {
+ const dto: BookingWebhookEventDTO = {
+ triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
+ createdAt: "2024-01-15T10:00:00Z",
+ booking: {
+ id: 1,
+ eventTypeId: 1,
+ userId: 1,
+ smsReminderNumber: null,
+ },
+ eventType: {
+ eventTitle: "Test",
+ eventDescription: "",
+ requiresConfirmation: false,
+ price: 0,
+ currency: "USD",
+ length: 30,
+ },
+ evt: {},
+ };
+
+ mockWebhookService.getSubscribers.mockResolvedValue(mockSubscribers);
+
+ await handler.handleNotification(dto);
+
+ expect(mockFactory.getBuilder).toHaveBeenCalledWith(WebhookVersion.V_2021_10_20, WebhookTriggerEvents.BOOKING_CREATED);
+ });
+ });
+});
+
diff --git a/packages/features/webhooks/lib/service/WebhookNotificationHandler.ts b/packages/features/webhooks/lib/service/WebhookNotificationHandler.ts
index 1a042d8eb9..d379ffbb13 100644
--- a/packages/features/webhooks/lib/service/WebhookNotificationHandler.ts
+++ b/packages/features/webhooks/lib/service/WebhookNotificationHandler.ts
@@ -1,28 +1,18 @@
-import { WebhookTriggerEvents } from "@calcom/prisma/enums";
-
+import { DEFAULT_WEBHOOK_VERSION } from "../interface/IWebhookRepository";
import type { WebhookEventDTO } from "../dto/types";
-import type { BookingPayloadBuilder } from "../factory/BookingPayloadBuilder";
-import type { FormPayloadBuilder } from "../factory/FormPayloadBuilder";
-import type { InstantMeetingBuilder } from "../factory/InstantMeetingBuilder";
-import type { MeetingPayloadBuilder } from "../factory/MeetingPayloadBuilder";
-import type { OOOPayloadBuilder } from "../factory/OOOPayloadBuilder";
-import type { RecordingPayloadBuilder } from "../factory/RecordingPayloadBuilder";
import type { WebhookPayload } from "../factory/types";
+import type { PayloadBuilderFactory } from "../factory/versioned/PayloadBuilderFactory";
import type { ILogger } from "../interface/infrastructure";
import type { IWebhookService } from "../interface/services";
import type { IWebhookNotificationHandler } from "../interface/webhook";
+import type { WebhookVersion } from "../interface/IWebhookRepository";
export class WebhookNotificationHandler implements IWebhookNotificationHandler {
private readonly log: ILogger;
constructor(
private readonly webhookService: IWebhookService,
- private readonly bookingPayloadBuilder: BookingPayloadBuilder,
- private readonly formPayloadBuilder: FormPayloadBuilder,
- private readonly oooPayloadBuilder: OOOPayloadBuilder,
- private readonly recordingPayloadBuilder: RecordingPayloadBuilder,
- private readonly meetingPayloadBuilder: MeetingPayloadBuilder,
- private readonly instantMeetingBuilder: InstantMeetingBuilder,
+ private readonly payloadBuilderFactory: PayloadBuilderFactory,
logger: ILogger
) {
this.log = logger.getSubLogger({ prefix: ["[WebhookNotificationHandler]"] });
@@ -76,72 +66,20 @@ export class WebhookNotificationHandler implements IWebhookNotificationHandler {
}
}
- private createPayload(dto: WebhookEventDTO): WebhookPayload {
- switch (dto.triggerEvent) {
- // Booking events
- case WebhookTriggerEvents.BOOKING_CREATED:
- case WebhookTriggerEvents.BOOKING_CANCELLED:
- case WebhookTriggerEvents.BOOKING_REQUESTED:
- case WebhookTriggerEvents.BOOKING_RESCHEDULED:
- case WebhookTriggerEvents.BOOKING_REJECTED:
- case WebhookTriggerEvents.BOOKING_PAID:
- case WebhookTriggerEvents.BOOKING_PAYMENT_INITIATED:
- case WebhookTriggerEvents.BOOKING_NO_SHOW_UPDATED:
- return this.bookingPayloadBuilder.build(dto);
-
- // Form events
- case WebhookTriggerEvents.FORM_SUBMITTED:
- case WebhookTriggerEvents.FORM_SUBMITTED_NO_EVENT:
- return this.formPayloadBuilder.build(dto);
-
- // OOO events
- case WebhookTriggerEvents.OOO_CREATED:
- return this.oooPayloadBuilder.build(dto);
-
- // Recording events
- case WebhookTriggerEvents.RECORDING_READY:
- case WebhookTriggerEvents.RECORDING_TRANSCRIPTION_GENERATED:
- return this.recordingPayloadBuilder.build(dto);
-
- // Meeting events
- case WebhookTriggerEvents.MEETING_STARTED:
- case WebhookTriggerEvents.MEETING_ENDED:
- return this.meetingPayloadBuilder.build(dto);
-
- // Instant meeting events
- case WebhookTriggerEvents.INSTANT_MEETING:
- return this.instantMeetingBuilder.build(dto);
-
- // No-show events (special handling)
- case WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW:
- case WebhookTriggerEvents.AFTER_GUESTS_CAL_VIDEO_NO_SHOW: {
- return {
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- payload: {
- bookingId: dto.bookingId,
- webhook: dto.webhook,
- },
- };
- }
-
- case WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR: {
- return {
- triggerEvent: dto.triggerEvent,
- createdAt: dto.createdAt,
- payload: {
- error: dto.error,
- credential: dto.credential,
- user: dto.user,
- },
- };
- }
-
- default: {
- // TypeScript exhaustiveness check - this should never happen if all cases are covered
- const _exhaustiveCheck: never = dto;
- throw new Error(`Unsupported triggerEvent: ${JSON.stringify(_exhaustiveCheck)}`);
- }
- }
+ /**
+ * Create webhook payload using version-specific builder from factory.
+ *
+ * All event types now go through the factory for consistent versioning.
+ *
+ * Note: Currently uses DEFAULT version for all subscribers.
+ * In the future, this can be enhanced to:
+ * 1. Accept subscriber version parameter
+ * 2. Build version-specific payloads per subscriber
+ * 3. Group subscribers by version for efficiency
+ */
+ private createPayload(dto: WebhookEventDTO, version: WebhookVersion = DEFAULT_WEBHOOK_VERSION): WebhookPayload {
+ // Get version-specific builder from factory - handles all event types
+ const builder = this.payloadBuilderFactory.getBuilder(version, dto.triggerEvent);
+ return builder.build(dto);
}
}
diff --git a/packages/features/webhooks/lib/subscriberUrlReserved.ts b/packages/features/webhooks/lib/subscriberUrlReserved.ts
index 8386e64484..6f9b6f8d5f 100644
--- a/packages/features/webhooks/lib/subscriberUrlReserved.ts
+++ b/packages/features/webhooks/lib/subscriberUrlReserved.ts
@@ -1,9 +1,9 @@
-import type { Webhook } from "@calcom/prisma/client";
+import type { WebhookForReservationCheck } from "./dto/types";
interface Params {
subscriberUrl: string;
id?: string;
- webhooks?: Webhook[];
+ webhooks?: WebhookForReservationCheck[];
teamId?: number;
userId?: number;
eventTypeId?: number;
@@ -23,20 +23,20 @@ export const subscriberUrlReserved = ({
throw new Error("Either teamId, userId, eventTypeId or platform must be provided.");
}
- const findMatchingWebhook = (condition: (webhook: Webhook) => void) => {
+ const findMatchingWebhook = (condition: (webhook: WebhookForReservationCheck) => boolean) => {
return !!webhooks?.find(
(webhook) => webhook.subscriberUrl === subscriberUrl && (!id || webhook.id !== id) && condition(webhook)
);
};
if (teamId) {
- return findMatchingWebhook((webhook: Webhook) => webhook.teamId === teamId);
+ return findMatchingWebhook((webhook) => webhook.teamId === teamId);
}
if (eventTypeId) {
- return findMatchingWebhook((webhook: Webhook) => webhook.eventTypeId === eventTypeId);
+ return findMatchingWebhook((webhook) => webhook.eventTypeId === eventTypeId);
}
if (platform) {
- return findMatchingWebhook((webhook: Webhook) => webhook.platform === true);
+ return findMatchingWebhook((webhook) => webhook.platform === true);
}
- return findMatchingWebhook((webhook: Webhook) => webhook.userId === userId);
+ return findMatchingWebhook((webhook) => webhook.userId === userId);
};
diff --git a/packages/features/webhooks/pages/webhook-edit-view.tsx b/packages/features/webhooks/pages/webhook-edit-view.tsx
index 48d87332cb..0b54c48756 100644
--- a/packages/features/webhooks/pages/webhook-edit-view.tsx
+++ b/packages/features/webhooks/pages/webhook-edit-view.tsx
@@ -2,11 +2,18 @@
import { useRouter } from "next/navigation";
+import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/SettingsHeaderWithBackButton";
+import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { WebhookVersion } from "../lib/interface/IWebhookRepository";
+import { WEBHOOK_VERSION_OPTIONS, getWebhookVersionLabel } from "../lib/constants";
import { trpc } from "@calcom/trpc/react";
+import { Select } from "@calcom/ui/components/form";
import { SkeletonContainer } from "@calcom/ui/components/skeleton";
import { showToast } from "@calcom/ui/components/toast";
+import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import type { WebhookFormSubmitData } from "../components/WebhookForm";
@@ -23,6 +30,7 @@ type WebhookProps = {
eventTriggers: WebhookTriggerEvents[];
secret: string | null;
platform: boolean;
+ version: WebhookVersion;
};
export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
@@ -57,47 +65,73 @@ export function EditWebhookView({ webhook }: { webhook?: WebhookProps }) {
if (isPending || !webhook) return ;
return (
- <>
- {
- if (
- subscriberUrlReserved({
- subscriberUrl: values.subscriberUrl,
- id: webhook.id,
- webhooks,
- teamId: webhook.teamId ?? undefined,
- userId: webhook.userId ?? undefined,
- platform: webhook.platform ?? undefined,
- })
- ) {
- showToast(t("webhook_subscriber_url_reserved"), "error");
- return;
- }
-
- if (values.changeSecret) {
- values.secret = values.newSecret.trim().length ? values.newSecret : null;
- }
-
- if (!values.payloadTemplate) {
- values.payloadTemplate = null;
- }
-
- editWebhookMutation.mutate({
- id: webhook.id,
+ (
+
+
+
+
+ }>
+ {children}
+
+ )}
+ onSubmit={(values: WebhookFormSubmitData) => {
+ if (
+ subscriberUrlReserved({
subscriberUrl: values.subscriberUrl,
- eventTriggers: values.eventTriggers,
- active: values.active,
- payloadTemplate: values.payloadTemplate,
- secret: values.secret,
- time: values.time,
- timeUnit: values.timeUnit,
- });
- }}
- apps={installedApps?.items.map((app) => app.slug)}
- />
- >
+ id: webhook.id,
+ webhooks,
+ teamId: webhook.teamId ?? undefined,
+ userId: webhook.userId ?? undefined,
+ platform: webhook.platform ?? undefined,
+ })
+ ) {
+ showToast(t("webhook_subscriber_url_reserved"), "error");
+ return;
+ }
+
+ if (values.changeSecret) {
+ values.secret = values.newSecret.trim().length ? values.newSecret : null;
+ }
+
+ if (!values.payloadTemplate) {
+ values.payloadTemplate = null;
+ }
+
+ editWebhookMutation.mutate({
+ id: webhook.id,
+ subscriberUrl: values.subscriberUrl,
+ eventTriggers: values.eventTriggers,
+ active: values.active,
+ payloadTemplate: values.payloadTemplate,
+ secret: values.secret,
+ time: values.time,
+ timeUnit: values.timeUnit,
+ version: values.version,
+ });
+ }}
+ apps={installedApps?.items.map((app) => app.slug)}
+ />
);
}
diff --git a/packages/features/webhooks/pages/webhook-new-view.tsx b/packages/features/webhooks/pages/webhook-new-view.tsx
index e3be7c23e1..663d35d872 100644
--- a/packages/features/webhooks/pages/webhook-new-view.tsx
+++ b/packages/features/webhooks/pages/webhook-new-view.tsx
@@ -7,9 +7,12 @@ import SettingsHeaderWithBackButton from "@calcom/features/settings/appDir/Setti
import { APP_NAME } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
+import { WEBHOOK_VERSION_OPTIONS, getWebhookVersionLabel } from "../lib/constants";
import { trpc } from "@calcom/trpc/react";
import type { RouterOutputs } from "@calcom/trpc/react";
+import { Select } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
+import { Tooltip } from "@calcom/ui/components/tooltip";
import { revalidateWebhooksList } from "@calcom/web/app/(use-page-wrapper)/settings/(settings-layout)/developer/webhooks/(with-loader)/actions";
import type { WebhookFormSubmitData } from "../components/WebhookForm";
@@ -70,22 +73,45 @@ export const NewWebhookView = ({ webhooks, installedApps }: Props) => {
secret: values.secret,
time: values.time,
timeUnit: values.timeUnit,
+ version: values.version,
teamId,
platform,
});
};
return (
-
- app.slug)}
- />
-
+ app.slug)}
+ headerWrapper={(formMethods, children) => (
+
+
+
+
+ }>
+ {children}
+
+ )}
+ />
);
};
diff --git a/packages/lib/test/builder.ts b/packages/lib/test/builder.ts
index d541b86f1b..dec6bb3bc9 100644
--- a/packages/lib/test/builder.ts
+++ b/packages/lib/test/builder.ts
@@ -3,8 +3,8 @@ import type { TFunction } from "i18next";
import getICalUID from "@calcom/emails/lib/getICalUID";
import type { Booking, EventType, Prisma, Webhook, BookingReference } from "@calcom/prisma/client";
-import { CreationSource } from "@calcom/prisma/enums";
-import { BookingStatus } from "@calcom/prisma/enums";
+import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
+import { CreationSource, BookingStatus } from "@calcom/prisma/enums";
import type { CalendarEvent, Person, VideoCallData } from "@calcom/types/Calendar";
export const buildVideoCallData = (callData?: Partial): VideoCallData => {
@@ -187,6 +187,7 @@ export const buildWebhook = (webhook?: Partial): Webhook => {
platformOAuthClientId: null,
time: null,
timeUnit: null,
+ version: WebhookVersion.V_2021_10_20,
...webhook,
platform: false,
};
diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts
index d4382874ba..fd2022a893 100644
--- a/packages/platform/libraries/index.ts
+++ b/packages/platform/libraries/index.ts
@@ -38,6 +38,11 @@ export {
WorkflowTemplates,
} from "@calcom/prisma/enums";
+export {
+ WebhookVersion,
+ DEFAULT_WEBHOOK_VERSION,
+} from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
+
export { getUsernameList } from "@calcom/features/eventtypes/lib/defaultEvents";
export { handleMarkNoShow };
diff --git a/packages/prisma/migrations/20251024025759_add_webhook_version/migration.sql b/packages/prisma/migrations/20251024025759_add_webhook_version/migration.sql
new file mode 100644
index 0000000000..93180334da
--- /dev/null
+++ b/packages/prisma/migrations/20251024025759_add_webhook_version/migration.sql
@@ -0,0 +1,2 @@
+-- AlterTable
+ALTER TABLE "public"."Webhook" ADD COLUMN "version" TEXT NOT NULL DEFAULT '2021-10-20';
diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma
index cf9cd5e906..6139410d06 100644
--- a/packages/prisma/schema.prisma
+++ b/packages/prisma/schema.prisma
@@ -1133,6 +1133,7 @@ model Webhook {
scheduledTriggers WebhookScheduledTriggers[]
time Int?
timeUnit TimeUnit?
+ version String @default("2021-10-20")
@@unique([userId, subscriberUrl], name: "courseIdentifier")
@@unique([platformOAuthClientId, subscriberUrl], name: "oauthclientwebhook")
diff --git a/packages/trpc/server/routers/viewer/webhook/_router.tsx b/packages/trpc/server/routers/viewer/webhook/_router.tsx
index b5accc2fe5..61e24f2754 100644
--- a/packages/trpc/server/routers/viewer/webhook/_router.tsx
+++ b/packages/trpc/server/routers/viewer/webhook/_router.tsx
@@ -1,3 +1,5 @@
+import type { Webhook } from "@calcom/features/webhooks/lib/dto/types";
+
import { router } from "../../../trpc";
import { ZCreateInputSchema } from "./create.schema";
import { ZDeleteInputSchema } from "./delete.schema";
@@ -22,7 +24,7 @@ const UNSTABLE_HANDLER_CACHE: WebhookRouterHandlerCache = {};
export const webhookRouter = router({
list: createWebhookPbacProcedure("webhook.read")
.input(ZListInputSchema)
- .query(async ({ ctx, input }) => {
+ .query(async ({ ctx, input }): Promise => {
if (!UNSTABLE_HANDLER_CACHE.list) {
UNSTABLE_HANDLER_CACHE.list = await import("./list.handler").then((mod) => mod.listHandler);
}
@@ -131,7 +133,7 @@ export const webhookRouter = router({
}),
getByViewer: createWebhookPbacProcedure("webhook.read", ["ADMIN", "OWNER", "MEMBER"]).query(
- async ({ ctx }) => {
+ async ({ ctx }): Promise => {
if (!UNSTABLE_HANDLER_CACHE.getByViewer) {
UNSTABLE_HANDLER_CACHE.getByViewer = await import("./getByViewer.handler").then(
(mod) => mod.getByViewerHandler
diff --git a/packages/trpc/server/routers/viewer/webhook/create.schema.ts b/packages/trpc/server/routers/viewer/webhook/create.schema.ts
index 661e0f9144..337642104c 100644
--- a/packages/trpc/server/routers/viewer/webhook/create.schema.ts
+++ b/packages/trpc/server/routers/viewer/webhook/create.schema.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import { TIME_UNIT } from "@calcom/features/ee/workflows/lib/constants";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
+import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { webhookIdAndEventTypeIdSchema } from "./types";
@@ -17,6 +18,7 @@ export const ZCreateInputSchema = webhookIdAndEventTypeIdSchema.extend({
platform: z.boolean().optional(),
time: z.number().nullable().optional(),
timeUnit: z.enum(TIME_UNIT).nullable().optional(),
+ version: z.nativeEnum(WebhookVersion).optional(),
});
export type TCreateInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/webhook/edit.schema.ts b/packages/trpc/server/routers/viewer/webhook/edit.schema.ts
index 1bb505b438..010766fe26 100644
--- a/packages/trpc/server/routers/viewer/webhook/edit.schema.ts
+++ b/packages/trpc/server/routers/viewer/webhook/edit.schema.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import { TIME_UNIT } from "@calcom/features/ee/workflows/lib/constants";
import { WEBHOOK_TRIGGER_EVENTS } from "@calcom/features/webhooks/lib/constants";
+import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { webhookIdAndEventTypeIdSchema } from "./types";
@@ -16,6 +17,7 @@ export const ZEditInputSchema = webhookIdAndEventTypeIdSchema.extend({
secret: z.string().optional().nullable(),
time: z.number().nullable().optional(),
timeUnit: z.enum(TIME_UNIT).nullable().optional(),
+ version: z.nativeEnum(WebhookVersion).optional(),
});
export type TEditInputSchema = z.infer;
diff --git a/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts
index 0d78f55f4d..6d2e048451 100644
--- a/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts
+++ b/packages/trpc/server/routers/viewer/webhook/getByViewer.handler.ts
@@ -1,5 +1,5 @@
+import type { WebhookGroup } from "@calcom/features/webhooks/lib/dto/types";
import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
-import type { Webhook } from "@calcom/prisma/client";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
type GetByViewerOptions = {
@@ -8,19 +8,6 @@ type GetByViewerOptions = {
};
};
-type WebhookGroup = {
- teamId?: number | null;
- profile: {
- slug: string | null;
- name: string | null;
- image?: string;
- };
- metadata?: {
- readOnly: boolean;
- };
- webhooks: Webhook[];
-};
-
export type WebhooksByViewer = {
webhookGroups: WebhookGroup[];
profiles: {
@@ -32,7 +19,7 @@ export type WebhooksByViewer = {
}[];
};
-export const getByViewerHandler = async ({ ctx }: GetByViewerOptions) => {
+export const getByViewerHandler = async ({ ctx }: GetByViewerOptions): Promise => {
// Use the singleton instance to avoid creating new instances repeatedly
const webhookRepository = WebhookRepository.getInstance();
return await webhookRepository.getFilteredWebhooksForUser({
diff --git a/packages/trpc/server/routers/viewer/webhook/list.handler.ts b/packages/trpc/server/routers/viewer/webhook/list.handler.ts
index 89c500d78a..a581126488 100644
--- a/packages/trpc/server/routers/viewer/webhook/list.handler.ts
+++ b/packages/trpc/server/routers/viewer/webhook/list.handler.ts
@@ -1,7 +1,5 @@
-import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service";
-import { prisma } from "@calcom/prisma";
-import type { Prisma } from "@calcom/prisma/client";
-import { MembershipRole } from "@calcom/prisma/enums";
+import type { Webhook } from "@calcom/features/webhooks/lib/dto/types";
+import { WebhookRepository } from "@calcom/features/webhooks/lib/repository/WebhookRepository";
import type { TrpcSessionUser } from "@calcom/trpc/server/types";
import type { TListInputSchema } from "./list.schema";
@@ -13,72 +11,13 @@ type ListOptions = {
input: TListInputSchema;
};
-export const listHandler = async ({ ctx, input }: ListOptions) => {
- const where: Prisma.WebhookWhereInput = {
- /* Don't mixup zapier webhooks with normal ones */
- AND: [{ appId: !input?.appId ? null : input.appId }],
- };
+export const listHandler = async ({ ctx, input }: ListOptions): Promise => {
+ const repository = WebhookRepository.getInstance();
- const user = await prisma.user.findUnique({
- where: {
- id: ctx.user.id,
- },
- select: {
- teams: true,
- },
- });
-
- if (Array.isArray(where.AND)) {
- if (input?.eventTypeId) {
- const managedParentEvt = await prisma.eventType.findFirst({
- where: {
- id: input.eventTypeId,
- parentId: {
- not: null,
- },
- },
- select: {
- parentId: true,
- },
- });
-
- if (managedParentEvt?.parentId) {
- where.AND?.push({
- OR: [{ eventTypeId: input.eventTypeId }, { eventTypeId: managedParentEvt.parentId, active: true }],
- });
- } else {
- where.AND?.push({ eventTypeId: input.eventTypeId });
- }
- } else {
- const permissionService = new PermissionCheckService();
- const teamIds = user?.teams?.map((m) => m.teamId) ?? [];
- const allowedTeamIds = (
- await Promise.all(
- teamIds.map(async (teamId) => {
- const ok = await permissionService.checkPermission({
- userId: ctx.user.id,
- teamId,
- permission: "webhook.read",
- fallbackRoles: [MembershipRole.ADMIN, MembershipRole.OWNER],
- });
- return ok ? teamId : null;
- })
- )
- ).filter((x): x is number => x !== null);
-
- console.log("Allowed Team IDs:", allowedTeamIds);
-
- where.AND?.push({
- OR: [{ userId: ctx.user.id }, ...(allowedTeamIds.length ? [{ teamId: { in: allowedTeamIds } }] : [])],
- });
- }
-
- if (input?.eventTriggers) {
- where.AND?.push({ eventTriggers: { hasEvery: input.eventTriggers } });
- }
- }
-
- return await prisma.webhook.findMany({
- where,
+ return repository.listWebhooks({
+ userId: ctx.user.id,
+ appId: input?.appId,
+ eventTypeId: input?.eventTypeId,
+ eventTriggers: input?.eventTriggers,
});
};