From 8b7497b8cd382b0787d8a1b66ac1681c39b3fae7 Mon Sep 17 00:00:00 2001
From: Syed Ali Shahbaz <52925846+alishaz-polymath@users.noreply.github.com>
Date: Sat, 27 Dec 2025 04:31:20 +0400
Subject: [PATCH] chore: Return webhook version in the header (#26139)
* init
* add tests
* fix type
* type fix
* fix
* fix tests
* fix test
---
.../developing/guides/automation/webhooks.mdx | 5 +
...scheduleNoShowTriggers.integration-test.ts | 8 +-
.../handleSeats/cancel/cancelAttendeeSeat.ts | 2 +
.../service/InstantBookingCreateService.ts | 7 +-
packages/features/tasker/tasks/sendWebook.ts | 3 +-
.../triggerFormSubmittedNoEventWebhook.ts | 9 +-
.../tasker/tasks/triggerNoShow/common.ts | 9 +-
.../tasker/tasks/triggerNoShow/schema.ts | 2 +
.../triggerNoShow/triggerGuestNoShow.test.ts | 6 +
.../triggerNoShow/triggerHostNoShow.test.ts | 5 +
.../handleWebhookScheduledTriggers.test.ts | 157 ++++++++++++++++
.../lib/handleWebhookScheduledTriggers.ts | 12 +-
.../features/webhooks/lib/scheduleTrigger.ts | 4 +-
.../features/webhooks/lib/sendPayload.test.ts | 111 +++++++++++
packages/features/webhooks/lib/sendPayload.ts | 5 +-
.../lib/service/WebhookService.test.ts | 172 ++++++++++++++++++
.../webhooks/lib/service/WebhookService.ts | 1 +
.../ooo/outOfOfficeCreateOrUpdate.handler.ts | 1 +
.../viewer/webhook/testTrigger.handler.ts | 5 +-
19 files changed, 506 insertions(+), 18 deletions(-)
create mode 100644 packages/features/webhooks/lib/handleWebhookScheduledTriggers.test.ts
create mode 100644 packages/features/webhooks/lib/sendPayload.test.ts
create mode 100644 packages/features/webhooks/lib/service/WebhookService.test.ts
diff --git a/docs/developing/guides/automation/webhooks.mdx b/docs/developing/guides/automation/webhooks.mdx
index 9476661cf2..d4d4a8952d 100644
--- a/docs/developing/guides/automation/webhooks.mdx
+++ b/docs/developing/guides/automation/webhooks.mdx
@@ -59,6 +59,11 @@ All webhook payloads are wrapped in the following structure:
}
```
+
+Webhook payloads are versioned. The version of the payload sent is included in the x-cal-webhook-version HTTP header.
+Example: x-cal-webhook-version: 2021-10-20
+
+
Select a version and trigger event to view the example payload:
diff --git a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.integration-test.ts b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.integration-test.ts
index b9e6c31b81..23ef45e057 100644
--- a/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.integration-test.ts
+++ b/packages/features/bookings/lib/handleNewBooking/scheduleNoShowTriggers.integration-test.ts
@@ -265,6 +265,7 @@ describe("scheduleNoShowTriggers Integration", () => {
payloadTemplate: null,
secret: null,
appId: null,
+ version: "2021-10-20",
},
});
@@ -273,8 +274,8 @@ describe("scheduleNoShowTriggers Integration", () => {
expect(sendGenericWebhookPayload).toHaveBeenCalledWith(
expect.objectContaining({
webhook: expect.objectContaining({
- id: hostWebhook.id,
subscriberUrl: "https://example.com/host-webhook",
+ version: "2021-10-20",
}),
triggerEvent: WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW,
data: expect.objectContaining({
@@ -287,6 +288,11 @@ describe("scheduleNoShowTriggers Integration", () => {
user_name: "Guest User",
}),
]),
+ webhook: expect.objectContaining({
+ id: hostWebhook.id,
+ subscriberUrl: "https://example.com/host-webhook",
+ version: "2021-10-20",
+ }),
}),
})
);
diff --git a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts
index a8a42070ad..93f1c14c1d 100644
--- a/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts
+++ b/packages/features/bookings/lib/handleSeats/cancel/cancelAttendeeSeat.ts
@@ -4,6 +4,7 @@ import { getDelegationCredentialOrFindRegularCredential } from "@calcom/app-stor
import { sendCancelledSeatEmailsAndSMS } from "@calcom/emails/email-manager";
import { updateMeeting } from "@calcom/features/conferencing/lib/videoClient";
import { WorkflowRepository } from "@calcom/features/ee/workflows/repositories/WorkflowRepository";
+import type { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import sendPayload from "@calcom/features/webhooks/lib/sendOrSchedulePayload";
import type { EventPayloadType, EventTypeInfo } from "@calcom/features/webhooks/lib/sendPayload";
import { getRichDescription } from "@calcom/lib/CalEventParser";
@@ -31,6 +32,7 @@ async function cancelAttendeeSeat(
payloadTemplate: string | null;
appId: string | null;
secret: string | null;
+ version: WebhookVersion;
}[];
evt: CalendarEvent;
eventTypeInfo: EventTypeInfo;
diff --git a/packages/features/bookings/lib/service/InstantBookingCreateService.ts b/packages/features/bookings/lib/service/InstantBookingCreateService.ts
index e6bda9c41d..768cdfeb7a 100644
--- a/packages/features/bookings/lib/service/InstantBookingCreateService.ts
+++ b/packages/features/bookings/lib/service/InstantBookingCreateService.ts
@@ -27,6 +27,7 @@ import { Prisma } from "@calcom/prisma/client";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { instantMeetingSubscriptionSchema as subscriptionSchema } from "../dto/schema";
+import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository";
interface IInstantBookingCreateServiceDependencies {
prismaClient: PrismaClient;
@@ -70,6 +71,7 @@ const handleInstantMeetingWebhookTrigger = async (args: {
payloadTemplate: true,
appId: true,
secret: true,
+ version: true,
},
});
@@ -80,7 +82,10 @@ const handleInstantMeetingWebhookTrigger = async (args: {
secretKey: sub.secret,
triggerEvent: eventTrigger,
createdAt: new Date().toISOString(),
- webhook: sub,
+ webhook: {
+ ...sub,
+ version: sub.version as WebhookVersion,
+ },
data: webhookData,
}).catch((e) => {
console.error(
diff --git a/packages/features/tasker/tasks/sendWebook.ts b/packages/features/tasker/tasks/sendWebook.ts
index b8bdb69954..e9a5a1bdda 100644
--- a/packages/features/tasker/tasks/sendWebook.ts
+++ b/packages/features/tasker/tasks/sendWebook.ts
@@ -1,5 +1,6 @@
import { z } from "zod";
+import { WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
const sendWebhookPayloadSchema = z.object({
@@ -10,6 +11,7 @@ const sendWebhookPayloadSchema = z.object({
subscriberUrl: z.string().url(),
appId: z.string().nullable(),
payloadTemplate: z.string().nullable(),
+ version: z.nativeEnum(WebhookVersion),
}),
// TODO: Define the data schema
data: z.any(),
@@ -22,7 +24,6 @@ export async function sendWebhook(payload: string): Promise {
);
await sendPayload(secretKey, triggerEvent, createdAt, webhook, data);
} catch (error) {
- // ... handle error
console.error(error);
throw error;
}
diff --git a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts
index ac89a89be7..67e4c06510 100644
--- a/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts
+++ b/packages/features/tasker/tasks/triggerFormSubmittedNoEvent/triggerFormSubmittedNoEventWebhook.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import type { FORM_SUBMITTED_WEBHOOK_RESPONSES } from "@calcom/app-store/routing-forms/lib/formSubmissionUtils";
import incompleteBookingActionFunctions from "@calcom/app-store/routing-forms/lib/incompleteBooking/actionFunctions";
+import { DEFAULT_WEBHOOK_VERSION, WebhookVersion } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import { sendGenericWebhookPayload } from "@calcom/features/webhooks/lib/sendPayload";
import prisma from "@calcom/prisma";
@@ -23,6 +24,7 @@ export const ZTriggerFormSubmittedNoEventWebhookPayloadSchema = z.object({
appId: z.string().nullable(),
payloadTemplate: z.string().nullable(),
secret: z.string().nullable(),
+ version: z.nativeEnum(WebhookVersion).optional(),
}),
responseId: z.number(),
responses: z.any(),
@@ -55,7 +57,12 @@ export async function triggerFormSubmittedNoEventWebhook(payload: string): Promi
secretKey: webhook.secret,
triggerEvent: "FORM_SUBMITTED_NO_EVENT",
createdAt: new Date().toISOString(),
- webhook,
+ webhook: {
+ subscriberUrl: webhook.subscriberUrl,
+ appId: webhook.appId,
+ payloadTemplate: webhook.payloadTemplate,
+ version: webhook.version ?? DEFAULT_WEBHOOK_VERSION,
+ },
data: {
formId: form.id,
formName: form.name,
diff --git a/packages/features/tasker/tasks/triggerNoShow/common.ts b/packages/features/tasker/tasks/triggerNoShow/common.ts
index 5fef3c95f0..31f3608d00 100644
--- a/packages/features/tasker/tasks/triggerNoShow/common.ts
+++ b/packages/features/tasker/tasks/triggerNoShow/common.ts
@@ -33,14 +33,19 @@ export function sendWebhookPayload(
participants: ParticipantsWithEmail,
originalRescheduledBooking?: OriginalRescheduledBooking,
hostEmail?: string
-): Promise {
+): Promise<{ ok: boolean; status: number } | void> {
const maxStartTimeHumanReadable = dayjs.unix(maxStartTime).format("YYYY-MM-DD HH:mm:ss Z");
return sendGenericWebhookPayload({
secretKey: webhook.secret,
triggerEvent,
createdAt: new Date().toISOString(),
- webhook,
+ webhook: {
+ subscriberUrl: webhook.subscriberUrl,
+ appId: webhook.appId,
+ payloadTemplate: webhook.payloadTemplate,
+ version: webhook.version,
+ },
data: {
title: booking.title,
bookingId: booking.id,
diff --git a/packages/features/tasker/tasks/triggerNoShow/schema.ts b/packages/features/tasker/tasks/triggerNoShow/schema.ts
index 355b6cbd0c..1a9858f474 100644
--- a/packages/features/tasker/tasks/triggerNoShow/schema.ts
+++ b/packages/features/tasker/tasks/triggerNoShow/schema.ts
@@ -2,6 +2,7 @@ import { z } from "zod";
import { TIME_UNIT } from "@calcom/features/ee/workflows/lib/constants";
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository";
const commonSchema = z.object({
triggerEvent: z.enum([
@@ -20,6 +21,7 @@ export const ZWebhook = z.object({
timeUnit: z.enum(TIME_UNIT),
eventTriggers: z.array(z.string()),
payloadTemplate: z.string().nullable(),
+ version: z.nativeEnum(WebhookVersion),
});
export type TWebhook = z.infer;
diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts
index d84f639e81..12443c869e 100644
--- a/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts
+++ b/packages/features/tasker/tasks/triggerNoShow/triggerGuestNoShow.test.ts
@@ -21,6 +21,7 @@ import { calculateMaxStartTime } from "./common";
import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName";
import type { TSendNoShowWebhookPayloadSchema } from "./schema";
import { triggerGuestNoShow } from "./triggerGuestNoShow";
+import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository";
vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({
getMeetingSessionsFromRoomName: vi.fn(),
@@ -141,6 +142,7 @@ describe("Trigger Guest No Show:", () => {
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
+ version: WebhookVersion.V_2021_10_20,
};
const payload = JSON.stringify({
@@ -327,6 +329,7 @@ describe("Trigger Guest No Show:", () => {
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
+ version: WebhookVersion.V_2021_10_20,
};
const payload = JSON.stringify({
@@ -552,6 +555,7 @@ describe("Trigger Guest No Show:", () => {
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
+ version: WebhookVersion.V_2021_10_20,
};
const payload = JSON.stringify({
@@ -737,6 +741,7 @@ describe("Trigger Guest No Show:", () => {
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
+ version: WebhookVersion.V_2021_10_20,
};
const payload = JSON.stringify({
@@ -889,6 +894,7 @@ describe("Trigger Guest No Show:", () => {
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
+ version: WebhookVersion.V_2021_10_20,
};
const payload = JSON.stringify({
diff --git a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts
index 408e19c2c9..2cd9dc845f 100644
--- a/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts
+++ b/packages/features/tasker/tasks/triggerNoShow/triggerHostNoShow.test.ts
@@ -21,6 +21,7 @@ import { calculateMaxStartTime } from "./common";
import { getMeetingSessionsFromRoomName } from "./getMeetingSessionsFromRoomName";
import type { TSendNoShowWebhookPayloadSchema } from "./schema";
import { triggerHostNoShow } from "./triggerHostNoShow";
+import { WebhookVersion } from "../../../webhooks/lib/interface/IWebhookRepository";
vi.mock("@calcom/features/tasker/tasks/triggerNoShow/getMeetingSessionsFromRoomName", () => ({
getMeetingSessionsFromRoomName: vi.fn(),
@@ -133,6 +134,7 @@ describe("Trigger Host No Show:", () => {
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
+ version: WebhookVersion.V_2021_10_20,
};
const payload = JSON.stringify({
@@ -298,6 +300,7 @@ describe("Trigger Host No Show:", () => {
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
+ version: WebhookVersion.V_2021_10_20,
};
const payload = JSON.stringify({
@@ -503,6 +506,7 @@ describe("Trigger Host No Show:", () => {
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
+ version: WebhookVersion.V_2021_10_20,
};
const payload = JSON.stringify({
@@ -653,6 +657,7 @@ describe("Trigger Host No Show:", () => {
timeUnit: TimeUnit.MINUTE,
payloadTemplate: null,
secret: null,
+ version: WebhookVersion.V_2021_10_20,
};
const payload = JSON.stringify({
diff --git a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.test.ts b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.test.ts
new file mode 100644
index 0000000000..4e4f8ef333
--- /dev/null
+++ b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.test.ts
@@ -0,0 +1,157 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { DEFAULT_WEBHOOK_VERSION } from "./interface/IWebhookRepository";
+import { handleWebhookScheduledTriggers } from "./handleWebhookScheduledTriggers";
+import type { PrismaClient } from "@calcom/prisma";
+
+describe("handleWebhookScheduledTriggers - X-Cal-Webhook-Version header", () => {
+ const mockFetch = vi.fn();
+ const now = new Date();
+
+ beforeEach(() => {
+ vi.stubGlobal("fetch", mockFetch);
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.resetAllMocks();
+ });
+
+ it("should include X-Cal-Webhook-Version header with webhook version from database", async () => {
+ const webhookVersion = "2021-10-20";
+ const mockPrisma = {
+ webhookScheduledTriggers: {
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: 1,
+ jobName: null,
+ subscriberUrl: "https://example.com/webhook",
+ payload: JSON.stringify({ triggerEvent: "MEETING_ENDED" }),
+ startAfter: new Date(now.getTime() - 60000), // 1 minute ago
+ webhook: {
+ secret: "test-secret",
+ version: webhookVersion,
+ },
+ },
+ ]),
+ delete: vi.fn().mockResolvedValue({}),
+ },
+ webhook: {
+ findUniqueOrThrow: vi.fn(),
+ },
+ };
+
+ await handleWebhookScheduledTriggers(mockPrisma as unknown as PrismaClient);
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [url, options] = mockFetch.mock.calls[0];
+
+ expect(url).toBe("https://example.com/webhook");
+ expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", webhookVersion);
+ expect(options.headers).toHaveProperty("X-Cal-Signature-256");
+ });
+
+ it("should use DEFAULT_WEBHOOK_VERSION when webhook has no version", async () => {
+ const mockPrisma = {
+ webhookScheduledTriggers: {
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: 1,
+ jobName: null,
+ subscriberUrl: "https://example.com/webhook",
+ payload: JSON.stringify({ triggerEvent: "MEETING_STARTED" }),
+ startAfter: new Date(now.getTime() - 60000),
+ webhook: {
+ secret: "test-secret",
+ version: null, // No version set
+ },
+ },
+ ]),
+ delete: vi.fn().mockResolvedValue({}),
+ },
+ webhook: {
+ findUniqueOrThrow: vi.fn(),
+ },
+ };
+
+ await handleWebhookScheduledTriggers(mockPrisma as unknown as PrismaClient);
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [, options] = mockFetch.mock.calls[0];
+
+ expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", DEFAULT_WEBHOOK_VERSION);
+ });
+
+ it("should use DEFAULT_WEBHOOK_VERSION when webhook relationship is null", async () => {
+ const mockPrisma = {
+ webhookScheduledTriggers: {
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: 1,
+ jobName: null,
+ subscriberUrl: "https://example.com/webhook",
+ payload: JSON.stringify({ triggerEvent: "MEETING_STARTED" }),
+ startAfter: new Date(now.getTime() - 60000),
+ webhook: null, // No webhook relationship
+ },
+ ]),
+ delete: vi.fn().mockResolvedValue({}),
+ },
+ webhook: {
+ findUniqueOrThrow: vi.fn(),
+ },
+ };
+
+ await handleWebhookScheduledTriggers(mockPrisma as unknown as PrismaClient);
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [, options] = mockFetch.mock.calls[0];
+
+ expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", DEFAULT_WEBHOOK_VERSION);
+ });
+
+ it("should fetch webhook version from database for legacy jobs using jobName", async () => {
+ const webhookVersion = "2021-10-20";
+ const mockPrisma = {
+ webhookScheduledTriggers: {
+ deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
+ findMany: vi.fn().mockResolvedValue([
+ {
+ id: 1,
+ jobName: "appId_webhookId123", // Legacy format
+ subscriberUrl: "https://example.com/webhook",
+ payload: JSON.stringify({ triggerEvent: "MEETING_ENDED" }),
+ startAfter: new Date(now.getTime() - 60000),
+ webhook: null, // No webhook relationship for legacy jobs
+ },
+ ]),
+ delete: vi.fn().mockResolvedValue({}),
+ },
+ webhook: {
+ findUniqueOrThrow: vi.fn().mockResolvedValue({
+ secret: "fetched-secret",
+ version: webhookVersion,
+ }),
+ },
+ };
+
+ await handleWebhookScheduledTriggers(mockPrisma as unknown as PrismaClient);
+
+ expect(mockPrisma.webhook.findUniqueOrThrow).toHaveBeenCalledWith({
+ where: { id: "webhookId123", appId: "appId" },
+ select: { secret: true, version: true },
+ });
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [, options] = mockFetch.mock.calls[0];
+
+ expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", webhookVersion);
+ });
+});
diff --git a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts
index b93791f937..b32fc28ab4 100644
--- a/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts
+++ b/packages/features/webhooks/lib/handleWebhookScheduledTriggers.ts
@@ -2,6 +2,7 @@ import dayjs from "@calcom/dayjs";
import logger from "@calcom/lib/logger";
import type { PrismaClient } from "@calcom/prisma";
+import { DEFAULT_WEBHOOK_VERSION } from "./interface/IWebhookRepository";
import { createWebhookSignature, jsonParse } from "./sendPayload";
export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
@@ -27,12 +28,13 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
webhook: {
select: {
secret: true,
+ version: true,
},
},
},
});
- const fetchPromises: Promise[] = [];
+ const fetchPromises: Promise[] = [];
// run jobs
for (const job of jobsToRun) {
@@ -45,6 +47,7 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
try {
webhook = await prisma.webhook.findUniqueOrThrow({
where: { id: subscriberId, appId: appId !== "null" ? appId : null },
+ select: { secret: true, version: true },
});
} catch {
logger.error(`Error finding webhook for subscriberId: ${subscriberId}, appId: ${appId}`);
@@ -54,6 +57,7 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
const headers: Record = {
"Content-Type":
!job.payload || jsonParse(job.payload) ? "application/json" : "application/x-www-form-urlencoded",
+ "X-Cal-Webhook-Version": webhook?.version ?? DEFAULT_WEBHOOK_VERSION,
};
if (webhook) {
@@ -71,12 +75,6 @@ export async function handleWebhookScheduledTriggers(prisma: PrismaClient) {
})
);
- const parsedJobPayload = JSON.parse(job.payload) as {
- id: number; // booking id
- endTime: string;
- triggerEvent: string;
- };
-
// clean finished job
await prisma.webhookScheduledTriggers.delete({
where: {
diff --git a/packages/features/webhooks/lib/scheduleTrigger.ts b/packages/features/webhooks/lib/scheduleTrigger.ts
index 3d60ec3b56..13b5ee5bd3 100644
--- a/packages/features/webhooks/lib/scheduleTrigger.ts
+++ b/packages/features/webhooks/lib/scheduleTrigger.ts
@@ -13,6 +13,7 @@ import { prisma } from "@calcom/prisma";
import type { Prisma, Webhook, Booking, ApiKey } from "@calcom/prisma/client";
import { BookingStatus, WebhookTriggerEvents } from "@calcom/prisma/enums";
import { bookingMetadataSchema } from "@calcom/prisma/zod-utils";
+import { DEFAULT_WEBHOOK_VERSION, type WebhookVersion } from "./interface/IWebhookRepository";
const SCHEDULING_TRIGGER: WebhookTriggerEvents[] = [
WebhookTriggerEvents.MEETING_ENDED,
@@ -657,7 +658,7 @@ export async function scheduleNoShowTaskForBooking(
triggerEvent === WebhookTriggerEvents.AFTER_HOSTS_CAL_VIDEO_NO_SHOW
? "triggerHostNoShowWebhook"
: "triggerGuestNoShowWebhook";
-
+ const version = (webhook.version as WebhookVersion) ?? DEFAULT_WEBHOOK_VERSION;
await tasker.create(
taskType,
{
@@ -667,6 +668,7 @@ export async function scheduleNoShowTaskForBooking(
...webhook,
time: webhook.time ?? 0,
timeUnit: webhook.timeUnit ?? "HOUR",
+ version,
},
},
{
diff --git a/packages/features/webhooks/lib/sendPayload.test.ts b/packages/features/webhooks/lib/sendPayload.test.ts
new file mode 100644
index 0000000000..788d5b658c
--- /dev/null
+++ b/packages/features/webhooks/lib/sendPayload.test.ts
@@ -0,0 +1,111 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { WebhookVersion } from "./interface/IWebhookRepository";
+import sendPayload from "./sendPayload";
+
+describe("sendPayload", () => {
+ const mockFetch = vi.fn();
+
+ beforeEach(() => {
+ vi.stubGlobal("fetch", mockFetch);
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ });
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.resetAllMocks();
+ });
+
+ describe("X-Cal-Webhook-Version header", () => {
+ it("should include X-Cal-Webhook-Version header with the webhook version", async () => {
+ const webhook = {
+ subscriberUrl: "https://example.com/webhook",
+ appId: null,
+ payloadTemplate: null,
+ version: WebhookVersion.V_2021_10_20,
+ };
+
+ await sendPayload("test-secret", "BOOKING_CREATED", new Date().toISOString(), webhook, {
+ title: "Test Booking",
+ startTime: "2024-01-01T10:00:00Z",
+ endTime: "2024-01-01T11:00:00Z",
+ organizer: {
+ email: "organizer@example.com",
+ name: "Organizer",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ attendees: [],
+ type: "test-event",
+ description: "",
+ } as unknown as Parameters[4]);
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [url, options] = mockFetch.mock.calls[0];
+
+ expect(url).toBe("https://example.com/webhook");
+ expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", "2021-10-20");
+ });
+
+ it("should include X-Cal-Signature-256 header alongside version header", async () => {
+ const webhook = {
+ subscriberUrl: "https://example.com/webhook",
+ appId: null,
+ payloadTemplate: null,
+ version: WebhookVersion.V_2021_10_20,
+ };
+
+ await sendPayload("test-secret", "BOOKING_CREATED", new Date().toISOString(), webhook, {
+ title: "Test Booking",
+ startTime: "2024-01-01T10:00:00Z",
+ endTime: "2024-01-01T11:00:00Z",
+ organizer: {
+ email: "organizer@example.com",
+ name: "Organizer",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ attendees: [],
+ type: "test-event",
+ description: "",
+ } as unknown as Parameters[4]);
+
+ const [, options] = mockFetch.mock.calls[0];
+
+ expect(options.headers).toHaveProperty("X-Cal-Signature-256");
+ expect(options.headers).toHaveProperty("X-Cal-Webhook-Version");
+ expect(options.headers).toHaveProperty("Content-Type", "application/json");
+ });
+
+ it("should send correct version for different webhook versions", async () => {
+ // Test with the current version
+ const webhook = {
+ subscriberUrl: "https://example.com/webhook",
+ appId: null,
+ payloadTemplate: null,
+ version: WebhookVersion.V_2021_10_20,
+ };
+
+ await sendPayload("test-secret", "BOOKING_CREATED", new Date().toISOString(), webhook, {
+ title: "Test",
+ startTime: "2024-01-01T10:00:00Z",
+ endTime: "2024-01-01T11:00:00Z",
+ organizer: {
+ email: "test@example.com",
+ name: "Test",
+ timeZone: "UTC",
+ language: { locale: "en" },
+ },
+ attendees: [],
+ type: "test",
+ description: "",
+ } as unknown as Parameters[4]);
+
+ const [, options] = mockFetch.mock.calls[0];
+ expect(options.headers["X-Cal-Webhook-Version"]).toBe("2021-10-20");
+ });
+ });
+});
diff --git a/packages/features/webhooks/lib/sendPayload.ts b/packages/features/webhooks/lib/sendPayload.ts
index 7dc096df52..faed63a257 100644
--- a/packages/features/webhooks/lib/sendPayload.ts
+++ b/packages/features/webhooks/lib/sendPayload.ts
@@ -9,7 +9,7 @@ import { getUTCOffsetByTimezone } from "@calcom/lib/dayjs";
import type { CalendarEvent, Person } from "@calcom/types/Calendar";
// Minimal webhook shape for sending payloads (subset of WebhookSubscriber)
-type WebhookForPayload = Pick;
+type WebhookForPayload = Pick;
type ContentType = "application/json" | "application/x-www-form-urlencoded";
@@ -309,7 +309,7 @@ const _sendPayload = async (
body: string,
contentType: "application/json" | "application/x-www-form-urlencoded"
) => {
- const { subscriberUrl } = webhook;
+ const { subscriberUrl, version } = webhook;
if (!subscriberUrl || !body) {
throw new Error("Missing required elements to send webhook payload.");
}
@@ -319,6 +319,7 @@ const _sendPayload = async (
headers: {
"Content-Type": contentType,
"X-Cal-Signature-256": createWebhookSignature({ secret: secretKey, body }),
+ "X-Cal-Webhook-Version": version,
},
redirect: "manual",
body,
diff --git a/packages/features/webhooks/lib/service/WebhookService.test.ts b/packages/features/webhooks/lib/service/WebhookService.test.ts
new file mode 100644
index 0000000000..87db5f2bfc
--- /dev/null
+++ b/packages/features/webhooks/lib/service/WebhookService.test.ts
@@ -0,0 +1,172 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+
+import { WebhookTriggerEvents } from "@calcom/prisma/enums";
+
+import type { WebhookSubscriber } from "../dto/types";
+import { IWebhookRepository, WebhookVersion } from "../interface/IWebhookRepository";
+import { WebhookService } from "./WebhookService";
+import { ILogger, ITasker } from "../interface/infrastructure";
+import { WebhookPayload } from "../factory/types";
+
+describe("WebhookService", () => {
+ let mockFetch: ReturnType;
+ let mockLogger: ReturnType;
+ let mockRepository: ReturnType;
+ let mockTasker: ReturnType;
+
+ function createMockLogger() {
+ const subLogger = {
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ getSubLogger: vi.fn(),
+ };
+ subLogger.getSubLogger.mockReturnValue(subLogger);
+
+ return {
+ getSubLogger: vi.fn().mockReturnValue(subLogger),
+ debug: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ };
+ }
+
+ function createMockRepository() {
+ return {
+ getSubscribers: vi.fn(),
+ getWebhookById: vi.fn(),
+ findByWebhookId: vi.fn(),
+ getFilteredWebhooksForUser: vi.fn(),
+ listWebhooks: vi.fn(),
+ };
+ }
+
+ function createMockTasker() {
+ return {
+ create: vi.fn(),
+ cancelWithReference: vi.fn(),
+ };
+ }
+
+ beforeEach(() => {
+ mockFetch = vi.fn();
+ mockLogger = createMockLogger();
+ mockRepository = createMockRepository();
+ mockTasker = createMockTasker();
+
+ vi.stubGlobal("fetch", mockFetch);
+ mockFetch.mockResolvedValue({
+ ok: true,
+ status: 200,
+ text: vi.fn().mockResolvedValue(""),
+ });
+ // Ensure TASKER_ENABLE_WEBHOOKS is not set so we use direct sending
+ delete process.env.TASKER_ENABLE_WEBHOOKS;
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ describe("X-Cal-Webhook-Version header", () => {
+ it("should include X-Cal-Webhook-Version header when sending webhook directly", async () => {
+ const service = new WebhookService(mockRepository as unknown as IWebhookRepository, mockTasker as unknown as ITasker, mockLogger as unknown as ILogger);
+
+ const subscriber: WebhookSubscriber = {
+ id: "webhook-1",
+ subscriberUrl: "https://example.com/webhook",
+ payloadTemplate: null,
+ appId: null,
+ secret: "test-secret",
+ eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
+ version: WebhookVersion.V_2021_10_20,
+ };
+
+ const payload = {
+ createdAt: new Date().toISOString(),
+ payload: { test: "data", triggerEvent: WebhookTriggerEvents.BOOKING_CREATED },
+ } as unknown as WebhookPayload;
+
+ await service.processWebhooks(WebhookTriggerEvents.BOOKING_CREATED, payload, [subscriber]);
+
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ const [url, options] = mockFetch.mock.calls[0];
+
+ expect(url).toBe("https://example.com/webhook");
+ expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", "2021-10-20");
+ expect(options.headers).toHaveProperty("X-Cal-Signature-256");
+ expect(options.headers).toHaveProperty("Content-Type", "application/json");
+ });
+
+ it("should include correct version for each subscriber", async () => {
+ const service = new WebhookService(mockRepository as unknown as IWebhookRepository, mockTasker as unknown as ITasker, mockLogger as unknown as ILogger);
+
+ const subscriber1: WebhookSubscriber = {
+ id: "webhook-1",
+ subscriberUrl: "https://example1.com/webhook",
+ payloadTemplate: null,
+ appId: null,
+ secret: "secret-1",
+ eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
+ version: WebhookVersion.V_2021_10_20,
+ };
+
+ const subscriber2: WebhookSubscriber = {
+ id: "webhook-2",
+ subscriberUrl: "https://example2.com/webhook",
+ payloadTemplate: null,
+ appId: null,
+ secret: "secret-2",
+ eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
+ version: WebhookVersion.V_2021_10_20,
+ };
+
+ const payload = {
+ createdAt: new Date().toISOString(),
+ payload: { test: "data", triggerEvent: WebhookTriggerEvents.BOOKING_CREATED },
+ } as unknown as WebhookPayload;
+
+ await service.processWebhooks(WebhookTriggerEvents.BOOKING_CREATED, payload, [subscriber1, subscriber2]);
+
+ expect(mockFetch).toHaveBeenCalledTimes(2);
+
+ // Check both calls have the version header
+ mockFetch.mock.calls.forEach((call) => {
+ const [, options] = call;
+ expect(options.headers).toHaveProperty("X-Cal-Webhook-Version", "2021-10-20");
+ });
+ });
+
+ it("should schedule webhook with version when TASKER_ENABLE_WEBHOOKS is enabled", async () => {
+ process.env.TASKER_ENABLE_WEBHOOKS = "1";
+
+ const service = new WebhookService(mockRepository as unknown as IWebhookRepository, mockTasker as unknown as ITasker, mockLogger as unknown as ILogger);
+
+ const subscriber: WebhookSubscriber = {
+ id: "webhook-1",
+ subscriberUrl: "https://example.com/webhook",
+ payloadTemplate: null,
+ appId: null,
+ secret: "test-secret",
+ eventTriggers: [WebhookTriggerEvents.BOOKING_CREATED],
+ version: WebhookVersion.V_2021_10_20,
+ };
+
+ const payload = {
+ createdAt: new Date().toISOString(),
+ payload: { test: "data", triggerEvent: WebhookTriggerEvents.BOOKING_CREATED },
+ } as unknown as WebhookPayload;
+
+ await service.processWebhooks(WebhookTriggerEvents.BOOKING_CREATED, payload, [subscriber]);
+
+ expect(mockTasker.create).toHaveBeenCalledTimes(1);
+ const [taskName, taskPayload] = mockTasker.create.mock.calls[0];
+
+ expect(taskName).toBe("sendWebhook");
+ const parsedPayload = JSON.parse(taskPayload);
+ expect(parsedPayload.webhook.version).toBe("2021-10-20");
+ });
+ });
+});
diff --git a/packages/features/webhooks/lib/service/WebhookService.ts b/packages/features/webhooks/lib/service/WebhookService.ts
index 159410c0b1..b559e5920a 100644
--- a/packages/features/webhooks/lib/service/WebhookService.ts
+++ b/packages/features/webhooks/lib/service/WebhookService.ts
@@ -100,6 +100,7 @@ export class WebhookService implements IWebhookService {
headers: {
"Content-Type": contentType,
"X-Cal-Signature-256": signature,
+ "X-Cal-Webhook-Version": subscriber.version,
},
redirect: "manual",
body,
diff --git a/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts b/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts
index a1668ccc07..cb3b8c8616 100644
--- a/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts
+++ b/packages/trpc/server/routers/viewer/ooo/outOfOfficeCreateOrUpdate.handler.ts
@@ -386,6 +386,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
appId: subscriber.appId,
subscriberUrl: subscriber.subscriberUrl,
payloadTemplate: subscriber.payloadTemplate,
+ version: subscriber.version,
},
payload
);
diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts
index f3b7c66c98..d19cd8e736 100644
--- a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts
+++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts
@@ -1,6 +1,7 @@
+import { DEFAULT_WEBHOOK_VERSION } from "@calcom/features/webhooks/lib/interface/IWebhookRepository";
import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload";
import sendPayload from "@calcom/features/webhooks/lib/sendPayload";
-import { getErrorFromUnknown } from "@calcom/lib/errors";
+
import { getTranslation } from "@calcom/lib/server/i18n";
import type { TTestTriggerInputSchema } from "./testTrigger.schema";
@@ -41,7 +42,7 @@ export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOption
};
try {
- const webhook = { subscriberUrl: url, appId: null, payloadTemplate };
+ const webhook = { subscriberUrl: url, appId: null, payloadTemplate, version: DEFAULT_WEBHOOK_VERSION };
return await sendPayload(secret, type, new Date().toISOString(), webhook, data);
} catch {
return {