chore: Return webhook version in the header (#26139)

* init

* add tests

* fix type

* type fix

* fix

* fix tests

* fix test
This commit is contained in:
Syed Ali Shahbaz authored and GitHub committed 2025-12-27 00:31:20 +00:00
1 parent 18017b5320
commit 8b7497b8cd
19 files changed
+506 -18

No files matched your search

@@ -59,6 +59,11 @@ All webhook payloads are wrapped in the following structure:
}
```
<Note>
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
</Note>
Select a version and trigger event to view the example payload:
<Tabs>
@@ -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",
}),
}),
})
);
@@ -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;
@@ -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(
+2 -1
View File
@@ -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<void> {
);
await sendPayload(secretKey, triggerEvent, createdAt, webhook, data);
} catch (error) {
// ... handle error
console.error(error);
throw error;
}
@@ -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,
@@ -33,14 +33,19 @@ export function sendWebhookPayload(
participants: ParticipantsWithEmail,
originalRescheduledBooking?: OriginalRescheduledBooking,
hostEmail?: string
): Promise<any> {
): 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,
@@ -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<typeof ZWebhook>;
@@ -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({
@@ -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({
@@ -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);
});
});
@@ -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<any>[] = [];
const fetchPromises: Promise<Response | void>[] = [];
// 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<string, string> = {
"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: {
@@ -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,
},
},
{
@@ -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<typeof sendPayload>[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<typeof sendPayload>[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<typeof sendPayload>[4]);
const [, options] = mockFetch.mock.calls[0];
expect(options.headers["X-Cal-Webhook-Version"]).toBe("2021-10-20");
});
});
});
@@ -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<WebhookSubscriber, "subscriberUrl" | "appId" | "payloadTemplate">;
type WebhookForPayload = Pick<WebhookSubscriber, "subscriberUrl" | "appId" | "payloadTemplate" | "version">;
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,
@@ -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<typeof vi.fn>;
let mockLogger: ReturnType<typeof createMockLogger>;
let mockRepository: ReturnType<typeof createMockRepository>;
let mockTasker: ReturnType<typeof createMockTasker>;
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");
});
});
});
@@ -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,
@@ -386,6 +386,7 @@ export const outOfOfficeCreateOrUpdate = async ({ ctx, input }: TBookingRedirect
appId: subscriber.appId,
subscriberUrl: subscriber.subscriberUrl,
payloadTemplate: subscriber.payloadTemplate,
version: subscriber.version,
},
payload
);
@@ -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 {