From 0334c76036746f325890ddfa286d0ced1b987586 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:30:08 -0700 Subject: [PATCH 01/10] feat(triggers): add Zoom webhook triggers with challenge-response and signature verification Add 6 Zoom webhook triggers (meeting started/ended, participant joined/left, recording completed, generic webhook) with full Zoom protocol support including endpoint.url_validation challenge-response handling and x-zm-signature HMAC-SHA256 verification. --- apps/sim/lib/webhooks/processor.ts | 2 +- apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/lib/webhooks/providers/zoom.ts | 134 ++++++++++ apps/sim/triggers/registry.ts | 14 + apps/sim/triggers/zoom/index.ts | 6 + apps/sim/triggers/zoom/meeting_ended.ts | 37 +++ apps/sim/triggers/zoom/meeting_started.ts | 40 +++ apps/sim/triggers/zoom/participant_joined.ts | 37 +++ apps/sim/triggers/zoom/participant_left.ts | 37 +++ apps/sim/triggers/zoom/recording_completed.ts | 37 +++ apps/sim/triggers/zoom/utils.ts | 253 ++++++++++++++++++ apps/sim/triggers/zoom/webhook.ts | 37 +++ 12 files changed, 635 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/webhooks/providers/zoom.ts create mode 100644 apps/sim/triggers/zoom/index.ts create mode 100644 apps/sim/triggers/zoom/meeting_ended.ts create mode 100644 apps/sim/triggers/zoom/meeting_started.ts create mode 100644 apps/sim/triggers/zoom/participant_joined.ts create mode 100644 apps/sim/triggers/zoom/participant_left.ts create mode 100644 apps/sim/triggers/zoom/recording_completed.ts create mode 100644 apps/sim/triggers/zoom/utils.ts create mode 100644 apps/sim/triggers/zoom/webhook.ts diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 38f6cc81bbc..4288d0287d6 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -117,7 +117,7 @@ export async function parseWebhookBody( } /** Providers that implement challenge/verification handling, checked before webhook lookup. */ -const CHALLENGE_PROVIDERS = ['slack', 'microsoft-teams', 'whatsapp'] as const +const CHALLENGE_PROVIDERS = ['slack', 'microsoft-teams', 'whatsapp', 'zoom'] as const export async function handleProviderChallenges( body: unknown, diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 1bea27ebc15..153f2c350e8 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -35,6 +35,7 @@ import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' import { webflowHandler } from '@/lib/webhooks/providers/webflow' import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp' +import { zoomHandler } from '@/lib/webhooks/providers/zoom' const logger = createLogger('WebhookProviderRegistry') @@ -72,6 +73,7 @@ const PROVIDER_HANDLERS: Record = { typeform: typeformHandler, webflow: webflowHandler, whatsapp: whatsappHandler, + zoom: zoomHandler, } /** diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts new file mode 100644 index 00000000000..24498903c3e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -0,0 +1,134 @@ +import crypto from 'crypto' +import { db, webhook } from '@sim/db' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + AuthContext, + EventMatchContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Zoom') + +/** + * Validate Zoom webhook signature using HMAC-SHA256. + * Zoom sends `x-zm-signature` as `v0=` and `x-zm-request-timestamp`. + * The message to hash is `v0:{timestamp}:{rawBody}`. + */ +function validateZoomSignature( + secretToken: string, + signature: string, + timestamp: string, + body: string +): boolean { + try { + if (!secretToken || !signature || !timestamp || !body) { + return false + } + + const message = `v0:${timestamp}:${body}` + const computedHash = crypto.createHmac('sha256', secretToken).update(message).digest('hex') + const expectedSignature = `v0=${computedHash}` + + return safeCompare(expectedSignature, signature) + } catch (err) { + logger.error('Zoom signature validation error', err) + return false + } +} + +export const zoomHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const secretToken = providerConfig.secretToken as string | undefined + if (!secretToken) { + return null + } + + const signature = request.headers.get('x-zm-signature') + const timestamp = request.headers.get('x-zm-request-timestamp') + + if (!signature || !timestamp) { + logger.warn(`[${requestId}] Zoom webhook missing signature or timestamp header`) + return new NextResponse('Unauthorized - Missing Zoom signature', { status: 401 }) + } + + if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) { + logger.warn(`[${requestId}] Zoom webhook signature verification failed`) + return new NextResponse('Unauthorized - Invalid Zoom signature', { status: 401 }) + } + + return null + }, + + async matchEvent({ webhook: wh, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + const event = obj.event as string | undefined + + if (triggerId) { + const { isZoomEventMatch } = await import('@/triggers/zoom/utils') + if (!isZoomEventMatch(triggerId, event || '')) { + logger.debug( + `[${requestId}] Zoom event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`, + { + webhookId: wh.id, + workflowId: workflow.id, + triggerId, + receivedEvent: event, + } + ) + return false + } + } + + return true + }, + + /** + * Handle Zoom endpoint URL validation challenges. + * Zoom sends an `endpoint.url_validation` event with a `plainToken` that must + * be hashed with the app's secret token and returned alongside the original token. + */ + async handleChallenge(body: unknown, _request: NextRequest, requestId: string, path: string) { + const obj = body as Record | null + if (obj?.event !== 'endpoint.url_validation') { + return null + } + + const payload = obj.payload as Record | undefined + const plainToken = payload?.plainToken as string | undefined + if (!plainToken) { + return null + } + + logger.info(`[${requestId}] Zoom URL validation request received for path: ${path}`) + + // Look up the webhook record to get the secret token from providerConfig + let secretToken = '' + try { + const webhooks = await db + .select() + .from(webhook) + .where(and(eq(webhook.path, path), isNull(webhook.deletedAt))) + if (webhooks.length > 0) { + const config = webhooks[0].providerConfig as Record | null + secretToken = (config?.secretToken as string) || '' + } + } catch (err) { + logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err) + } + + const hashForValidate = crypto + .createHmac('sha256', secretToken) + .update(plainToken) + .digest('hex') + + return NextResponse.json({ + plainToken, + encryptedToken: hashForValidate, + }) + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index eea378bfb99..79f8e64a641 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -212,6 +212,14 @@ import { webflowFormSubmissionTrigger, } from '@/triggers/webflow' import { whatsappWebhookTrigger } from '@/triggers/whatsapp' +import { + zoomMeetingEndedTrigger, + zoomMeetingStartedTrigger, + zoomParticipantJoinedTrigger, + zoomParticipantLeftTrigger, + zoomRecordingCompletedTrigger, + zoomWebhookTrigger, +} from '@/triggers/zoom' export const TRIGGER_REGISTRY: TriggerRegistry = { slack_webhook: slackWebhookTrigger, @@ -395,4 +403,10 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { intercom_contact_created: intercomContactCreatedTrigger, intercom_user_created: intercomUserCreatedTrigger, intercom_webhook: intercomWebhookTrigger, + zoom_meeting_started: zoomMeetingStartedTrigger, + zoom_meeting_ended: zoomMeetingEndedTrigger, + zoom_participant_joined: zoomParticipantJoinedTrigger, + zoom_participant_left: zoomParticipantLeftTrigger, + zoom_recording_completed: zoomRecordingCompletedTrigger, + zoom_webhook: zoomWebhookTrigger, } diff --git a/apps/sim/triggers/zoom/index.ts b/apps/sim/triggers/zoom/index.ts new file mode 100644 index 00000000000..b6aad68878b --- /dev/null +++ b/apps/sim/triggers/zoom/index.ts @@ -0,0 +1,6 @@ +export { zoomMeetingEndedTrigger } from './meeting_ended' +export { zoomMeetingStartedTrigger } from './meeting_started' +export { zoomParticipantJoinedTrigger } from './participant_joined' +export { zoomParticipantLeftTrigger } from './participant_left' +export { zoomRecordingCompletedTrigger } from './recording_completed' +export { zoomWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/zoom/meeting_ended.ts b/apps/sim/triggers/zoom/meeting_ended.ts new file mode 100644 index 00000000000..6d11d590d22 --- /dev/null +++ b/apps/sim/triggers/zoom/meeting_ended.ts @@ -0,0 +1,37 @@ +import { ZoomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildMeetingOutputs, + zoomSecretTokenField, + zoomSetupInstructions, + zoomTriggerOptions, +} from '@/triggers/zoom/utils' + +/** + * Zoom Meeting Ended Trigger + */ +export const zoomMeetingEndedTrigger: TriggerConfig = { + id: 'zoom_meeting_ended', + name: 'Zoom Meeting Ended', + provider: 'zoom', + description: 'Trigger workflow when a Zoom meeting ends', + version: '1.0.0', + icon: ZoomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zoom_meeting_ended', + triggerOptions: zoomTriggerOptions, + setupInstructions: zoomSetupInstructions('meeting_ended'), + extraFields: [zoomSecretTokenField('zoom_meeting_ended')], + }), + + outputs: buildMeetingOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/zoom/meeting_started.ts b/apps/sim/triggers/zoom/meeting_started.ts new file mode 100644 index 00000000000..b1e559f532b --- /dev/null +++ b/apps/sim/triggers/zoom/meeting_started.ts @@ -0,0 +1,40 @@ +import { ZoomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildMeetingOutputs, + zoomSecretTokenField, + zoomSetupInstructions, + zoomTriggerOptions, +} from '@/triggers/zoom/utils' + +/** + * Zoom Meeting Started Trigger + * + * Primary trigger - includes the dropdown for selecting trigger type. + */ +export const zoomMeetingStartedTrigger: TriggerConfig = { + id: 'zoom_meeting_started', + name: 'Zoom Meeting Started', + provider: 'zoom', + description: 'Trigger workflow when a Zoom meeting starts', + version: '1.0.0', + icon: ZoomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zoom_meeting_started', + triggerOptions: zoomTriggerOptions, + includeDropdown: true, + setupInstructions: zoomSetupInstructions('meeting_started'), + extraFields: [zoomSecretTokenField('zoom_meeting_started')], + }), + + outputs: buildMeetingOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/zoom/participant_joined.ts b/apps/sim/triggers/zoom/participant_joined.ts new file mode 100644 index 00000000000..573adbbe113 --- /dev/null +++ b/apps/sim/triggers/zoom/participant_joined.ts @@ -0,0 +1,37 @@ +import { ZoomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildParticipantOutputs, + zoomSecretTokenField, + zoomSetupInstructions, + zoomTriggerOptions, +} from '@/triggers/zoom/utils' + +/** + * Zoom Participant Joined Trigger + */ +export const zoomParticipantJoinedTrigger: TriggerConfig = { + id: 'zoom_participant_joined', + name: 'Zoom Participant Joined', + provider: 'zoom', + description: 'Trigger workflow when a participant joins a Zoom meeting', + version: '1.0.0', + icon: ZoomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zoom_participant_joined', + triggerOptions: zoomTriggerOptions, + setupInstructions: zoomSetupInstructions('participant_joined'), + extraFields: [zoomSecretTokenField('zoom_participant_joined')], + }), + + outputs: buildParticipantOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/zoom/participant_left.ts b/apps/sim/triggers/zoom/participant_left.ts new file mode 100644 index 00000000000..d88e325448a --- /dev/null +++ b/apps/sim/triggers/zoom/participant_left.ts @@ -0,0 +1,37 @@ +import { ZoomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildParticipantOutputs, + zoomSecretTokenField, + zoomSetupInstructions, + zoomTriggerOptions, +} from '@/triggers/zoom/utils' + +/** + * Zoom Participant Left Trigger + */ +export const zoomParticipantLeftTrigger: TriggerConfig = { + id: 'zoom_participant_left', + name: 'Zoom Participant Left', + provider: 'zoom', + description: 'Trigger workflow when a participant leaves a Zoom meeting', + version: '1.0.0', + icon: ZoomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zoom_participant_left', + triggerOptions: zoomTriggerOptions, + setupInstructions: zoomSetupInstructions('participant_left'), + extraFields: [zoomSecretTokenField('zoom_participant_left')], + }), + + outputs: buildParticipantOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/zoom/recording_completed.ts b/apps/sim/triggers/zoom/recording_completed.ts new file mode 100644 index 00000000000..bbae2eb6340 --- /dev/null +++ b/apps/sim/triggers/zoom/recording_completed.ts @@ -0,0 +1,37 @@ +import { ZoomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildRecordingOutputs, + zoomSecretTokenField, + zoomSetupInstructions, + zoomTriggerOptions, +} from '@/triggers/zoom/utils' + +/** + * Zoom Recording Completed Trigger + */ +export const zoomRecordingCompletedTrigger: TriggerConfig = { + id: 'zoom_recording_completed', + name: 'Zoom Recording Completed', + provider: 'zoom', + description: 'Trigger workflow when a Zoom cloud recording is completed', + version: '1.0.0', + icon: ZoomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zoom_recording_completed', + triggerOptions: zoomTriggerOptions, + setupInstructions: zoomSetupInstructions('recording_completed'), + extraFields: [zoomSecretTokenField('zoom_recording_completed')], + }), + + outputs: buildRecordingOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/zoom/utils.ts b/apps/sim/triggers/zoom/utils.ts new file mode 100644 index 00000000000..ab50c4e47de --- /dev/null +++ b/apps/sim/triggers/zoom/utils.ts @@ -0,0 +1,253 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Maps trigger IDs to the Zoom webhook event names they should match. + */ +const ZOOM_TRIGGER_EVENT_MAP: Record = { + zoom_meeting_started: ['meeting.started'], + zoom_meeting_ended: ['meeting.ended'], + zoom_participant_joined: ['meeting.participant_joined'], + zoom_participant_left: ['meeting.participant_left'], + zoom_recording_completed: ['recording.completed'], +} + +/** + * Checks whether a Zoom webhook payload matches the configured trigger type. + * Returns true for the generic `zoom_webhook` trigger (accepts all events). + */ +export function isZoomEventMatch(triggerId: string, event: string): boolean { + if (triggerId === 'zoom_webhook') { + return true + } + + const allowedEvents = ZOOM_TRIGGER_EVENT_MAP[triggerId] + if (!allowedEvents) { + return true + } + + return allowedEvents.includes(event) +} + +/** + * Dropdown options for the Zoom trigger type selector. + */ +export const zoomTriggerOptions = [ + { label: 'Meeting Started', id: 'zoom_meeting_started' }, + { label: 'Meeting Ended', id: 'zoom_meeting_ended' }, + { label: 'Participant Joined', id: 'zoom_participant_joined' }, + { label: 'Participant Left', id: 'zoom_participant_left' }, + { label: 'Recording Completed', id: 'zoom_recording_completed' }, + { label: 'Generic Webhook (All Events)', id: 'zoom_webhook' }, +] + +type ZoomEventType = + | 'meeting_started' + | 'meeting_ended' + | 'participant_joined' + | 'participant_left' + | 'recording_completed' + | 'generic' + +/** + * Generates setup instructions HTML for Zoom triggers. + */ +export function zoomSetupInstructions(eventType: ZoomEventType): string { + const eventNames: Record = { + meeting_started: 'meeting.started', + meeting_ended: 'meeting.ended', + participant_joined: 'meeting.participant_joined', + participant_left: 'meeting.participant_left', + recording_completed: 'recording.completed', + generic: 'your desired event type(s)', + } + + const instructions = [ + 'Copy the Webhook URL above.', + 'Go to the Zoom Marketplace and open your app (or create a new Webhook Only app).', + "Copy the Secret Token from your Zoom app's Features page and paste it in the Secret Token field above.", + 'Click "Save Configuration" above to activate the trigger.', + 'Navigate to Features > Event Subscriptions and click Add Event Subscription.', + 'Enter a subscription name and paste the webhook URL into the Event notification endpoint URL field.', + 'Click Validate to verify the endpoint.', + `Click Add Events and select the ${eventNames[eventType]} event type.`, + 'Save the event subscription in Zoom.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Creates the secret token field subBlock for a Zoom trigger. + */ +export function zoomSecretTokenField(triggerId: string): SubBlockConfig { + return { + id: 'secretToken', + title: 'Secret Token', + type: 'short-input', + placeholder: 'Enter your Zoom app Secret Token', + description: + "Found in your Zoom app's Features page. Required for endpoint validation and webhook signature verification.", + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: triggerId, + }, + } +} + +/** + * Builds outputs for meeting lifecycle events (started/ended). + */ +export function buildMeetingOutputs(): Record { + return { + event: { + type: 'string', + description: 'The webhook event type (e.g., meeting.started)', + }, + event_ts: { + type: 'number', + description: 'Unix timestamp in milliseconds when the event occurred', + }, + payload: { + account_id: { + type: 'string', + description: 'Zoom account ID', + }, + object: { + type: 'object', + description: 'Meeting details', + properties: { + id: { type: 'number', description: 'Meeting ID' }, + uuid: { type: 'string', description: 'Meeting UUID' }, + topic: { type: 'string', description: 'Meeting topic' }, + meeting_type: { type: 'number', description: 'Meeting type (1=instant, 2=scheduled, etc.)' }, + host_id: { type: 'string', description: 'Host user ID' }, + start_time: { type: 'string', description: 'Meeting start time (ISO 8601)' }, + end_time: { + type: 'string', + description: 'Meeting end time (ISO 8601, present on meeting.ended)', + }, + timezone: { type: 'string', description: 'Meeting timezone' }, + duration: { type: 'number', description: 'Meeting duration in minutes' }, + }, + }, + }, + } as any +} + +/** + * Builds outputs for participant events (joined/left). + */ +export function buildParticipantOutputs(): Record { + return { + event: { + type: 'string', + description: 'The webhook event type (e.g., meeting.participant_joined)', + }, + event_ts: { + type: 'number', + description: 'Unix timestamp in milliseconds when the event occurred', + }, + payload: { + account_id: { + type: 'string', + description: 'Zoom account ID', + }, + object: { + type: 'object', + description: 'Meeting details', + properties: { + id: { type: 'number', description: 'Meeting ID' }, + uuid: { type: 'string', description: 'Meeting UUID' }, + topic: { type: 'string', description: 'Meeting topic' }, + host_id: { type: 'string', description: 'Host user ID' }, + participant: { + type: 'object', + description: 'Participant details', + properties: { + id: { type: 'string', description: 'Participant user ID' }, + user_id: { type: 'string', description: 'Participant user ID (16-digit)' }, + user_name: { type: 'string', description: 'Participant display name' }, + email: { type: 'string', description: 'Participant email' }, + join_time: { type: 'string', description: 'Time participant joined (ISO 8601)' }, + leave_time: { + type: 'string', + description: 'Time participant left (ISO 8601, present on participant_left)', + }, + }, + }, + }, + }, + }, + } as any +} + +/** + * Builds outputs for recording completed events. + */ +export function buildRecordingOutputs(): Record { + return { + event: { + type: 'string', + description: 'The webhook event type (recording.completed)', + }, + event_ts: { + type: 'number', + description: 'Unix timestamp in milliseconds when the event occurred', + }, + payload: { + account_id: { + type: 'string', + description: 'Zoom account ID', + }, + object: { + type: 'object', + description: 'Recording details', + properties: { + id: { type: 'number', description: 'Meeting ID' }, + uuid: { type: 'string', description: 'Meeting UUID' }, + topic: { type: 'string', description: 'Meeting topic' }, + host_id: { type: 'string', description: 'Host user ID' }, + host_email: { type: 'string', description: 'Host email' }, + start_time: { type: 'string', description: 'Recording start time (ISO 8601)' }, + duration: { type: 'number', description: 'Recording duration in minutes' }, + total_size: { type: 'number', description: 'Total recording size in bytes' }, + recording_count: { type: 'number', description: 'Number of recording files' }, + share_url: { type: 'string', description: 'URL to share the recording' }, + recording_files: { + type: 'json', + description: 'Array of recording file objects with download URLs', + }, + }, + }, + }, + } as any +} + +/** + * Builds outputs for generic webhook (any event type). + */ +export function buildGenericOutputs(): Record { + return { + event: { + type: 'string', + description: 'The webhook event type (e.g., meeting.started, recording.completed)', + }, + event_ts: { + type: 'number', + description: 'Unix timestamp in milliseconds when the event occurred', + }, + payload: { + type: 'json', + description: 'Complete webhook payload (structure varies by event type)', + }, + } +} diff --git a/apps/sim/triggers/zoom/webhook.ts b/apps/sim/triggers/zoom/webhook.ts new file mode 100644 index 00000000000..b7695c2fa52 --- /dev/null +++ b/apps/sim/triggers/zoom/webhook.ts @@ -0,0 +1,37 @@ +import { ZoomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import type { TriggerConfig } from '@/triggers/types' +import { + buildGenericOutputs, + zoomSecretTokenField, + zoomSetupInstructions, + zoomTriggerOptions, +} from '@/triggers/zoom/utils' + +/** + * Generic Zoom webhook trigger that accepts any event type. + */ +export const zoomWebhookTrigger: TriggerConfig = { + id: 'zoom_webhook', + name: 'Zoom Webhook (All Events)', + provider: 'zoom', + description: 'Trigger workflow on any Zoom webhook event', + version: '1.0.0', + icon: ZoomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'zoom_webhook', + triggerOptions: zoomTriggerOptions, + setupInstructions: zoomSetupInstructions('generic'), + extraFields: [zoomSecretTokenField('zoom_webhook')], + }), + + outputs: buildGenericOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} From 272042560b2185dae9bc103f41978bb1bd1676e6 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:36:03 -0700 Subject: [PATCH 02/10] fix(triggers): use webhook.isActive instead of non-existent deletedAt column --- apps/sim/lib/webhooks/providers/zoom.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 24498903c3e..06a09832ae2 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -1,7 +1,7 @@ import crypto from 'crypto' import { db, webhook } from '@sim/db' import { createLogger } from '@sim/logger' -import { and, eq, isNull } from 'drizzle-orm' +import { and, eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { safeCompare } from '@/lib/core/security/encryption' @@ -112,7 +112,7 @@ export const zoomHandler: WebhookProviderHandler = { const webhooks = await db .select() .from(webhook) - .where(and(eq(webhook.path, path), isNull(webhook.deletedAt))) + .where(and(eq(webhook.path, path), eq(webhook.isActive, true))) if (webhooks.length > 0) { const config = webhooks[0].providerConfig as Record | null secretToken = (config?.secretToken as string) || '' From db37b6ebf3fa99e359fc3a694f42505e360b935b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:36:45 -0700 Subject: [PATCH 03/10] fix(triggers): address PR review feedback for Zoom webhooks - Add 30s timestamp freshness check to prevent replay attacks - Return null from handleChallenge when no secret token found instead of responding with empty-key HMAC - Remove all `as any` casts from output builder functions --- apps/sim/lib/webhooks/providers/zoom.ts | 12 ++++++++++++ apps/sim/triggers/zoom/utils.ts | 6 +++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 06a09832ae2..566791b7ee6 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -29,6 +29,12 @@ function validateZoomSignature( return false } + const nowSeconds = Math.floor(Date.now() / 1000) + const requestSeconds = parseInt(timestamp, 10) + if (isNaN(requestSeconds) || Math.abs(nowSeconds - requestSeconds) > 30) { + return false + } + const message = `v0:${timestamp}:${body}` const computedHash = crypto.createHmac('sha256', secretToken).update(message).digest('hex') const expectedSignature = `v0=${computedHash}` @@ -119,6 +125,12 @@ export const zoomHandler: WebhookProviderHandler = { } } catch (err) { logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err) + return null + } + + if (!secretToken) { + logger.warn(`[${requestId}] No secret token configured for Zoom URL validation on path: ${path}`) + return null } const hashForValidate = crypto diff --git a/apps/sim/triggers/zoom/utils.ts b/apps/sim/triggers/zoom/utils.ts index ab50c4e47de..b0ab69bf588 100644 --- a/apps/sim/triggers/zoom/utils.ts +++ b/apps/sim/triggers/zoom/utils.ts @@ -140,7 +140,7 @@ export function buildMeetingOutputs(): Record { }, }, }, - } as any + } } /** @@ -187,7 +187,7 @@ export function buildParticipantOutputs(): Record { }, }, }, - } as any + } } /** @@ -229,7 +229,7 @@ export function buildRecordingOutputs(): Record { }, }, }, - } as any + } } /** From 9901a776f264da337d03dfe89bfa705982c06bc0 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:43:46 -0700 Subject: [PATCH 04/10] lint --- .../integrations/data/integrations.json | 82 ++++++++++++++++++- apps/sim/lib/webhooks/providers/zoom.ts | 8 +- 2 files changed, 84 insertions(+), 6 deletions(-) diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 189e0a3b243..9bd9ced1f5f 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -5528,6 +5528,11 @@ "name": "HubSpot Contact Deleted", "description": "Trigger workflow when a contact is deleted in HubSpot" }, + { + "id": "hubspot_contact_merged", + "name": "HubSpot Contact Merged", + "description": "Trigger workflow when contacts are merged in HubSpot" + }, { "id": "hubspot_contact_privacy_deleted", "name": "HubSpot Contact Privacy Deleted", @@ -5538,6 +5543,11 @@ "name": "HubSpot Contact Property Changed", "description": "Trigger workflow when any property of a contact is updated in HubSpot" }, + { + "id": "hubspot_contact_restored", + "name": "HubSpot Contact Restored", + "description": "Trigger workflow when a deleted contact is restored in HubSpot" + }, { "id": "hubspot_company_created", "name": "HubSpot Company Created", @@ -5548,11 +5558,21 @@ "name": "HubSpot Company Deleted", "description": "Trigger workflow when a company is deleted in HubSpot" }, + { + "id": "hubspot_company_merged", + "name": "HubSpot Company Merged", + "description": "Trigger workflow when companies are merged in HubSpot" + }, { "id": "hubspot_company_property_changed", "name": "HubSpot Company Property Changed", "description": "Trigger workflow when any property of a company is updated in HubSpot" }, + { + "id": "hubspot_company_restored", + "name": "HubSpot Company Restored", + "description": "Trigger workflow when a deleted company is restored in HubSpot" + }, { "id": "hubspot_conversation_creation", "name": "HubSpot Conversation Creation", @@ -5588,11 +5608,21 @@ "name": "HubSpot Deal Deleted", "description": "Trigger workflow when a deal is deleted in HubSpot" }, + { + "id": "hubspot_deal_merged", + "name": "HubSpot Deal Merged", + "description": "Trigger workflow when deals are merged in HubSpot" + }, { "id": "hubspot_deal_property_changed", "name": "HubSpot Deal Property Changed", "description": "Trigger workflow when any property of a deal is updated in HubSpot" }, + { + "id": "hubspot_deal_restored", + "name": "HubSpot Deal Restored", + "description": "Trigger workflow when a deleted deal is restored in HubSpot" + }, { "id": "hubspot_ticket_created", "name": "HubSpot Ticket Created", @@ -5603,13 +5633,28 @@ "name": "HubSpot Ticket Deleted", "description": "Trigger workflow when a ticket is deleted in HubSpot" }, + { + "id": "hubspot_ticket_merged", + "name": "HubSpot Ticket Merged", + "description": "Trigger workflow when tickets are merged in HubSpot" + }, { "id": "hubspot_ticket_property_changed", "name": "HubSpot Ticket Property Changed", "description": "Trigger workflow when any property of a ticket is updated in HubSpot" + }, + { + "id": "hubspot_ticket_restored", + "name": "HubSpot Ticket Restored", + "description": "Trigger workflow when a deleted ticket is restored in HubSpot" + }, + { + "id": "hubspot_webhook", + "name": "HubSpot Webhook (All Events)", + "description": "Trigger workflow on any HubSpot webhook event" } ], - "triggerCount": 18, + "triggerCount": 27, "authType": "oauth", "category": "tools", "integrationType": "crm", @@ -10217,8 +10262,39 @@ } ], "operationCount": 35, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "salesforce_record_created", + "name": "Salesforce Record Created", + "description": "Trigger workflow when a Salesforce record is created" + }, + { + "id": "salesforce_record_updated", + "name": "Salesforce Record Updated", + "description": "Trigger workflow when a Salesforce record is updated" + }, + { + "id": "salesforce_record_deleted", + "name": "Salesforce Record Deleted", + "description": "Trigger workflow when a Salesforce record is deleted" + }, + { + "id": "salesforce_opportunity_stage_changed", + "name": "Salesforce Opportunity Stage Changed", + "description": "Trigger workflow when an opportunity stage changes" + }, + { + "id": "salesforce_case_status_changed", + "name": "Salesforce Case Status Changed", + "description": "Trigger workflow when a case status changes" + }, + { + "id": "salesforce_webhook", + "name": "Salesforce Webhook (All Events)", + "description": "Trigger workflow on any Salesforce webhook event" + } + ], + "triggerCount": 6, "authType": "oauth", "category": "tools", "integrationType": "crm", diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 566791b7ee6..998602edd3d 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -30,8 +30,8 @@ function validateZoomSignature( } const nowSeconds = Math.floor(Date.now() / 1000) - const requestSeconds = parseInt(timestamp, 10) - if (isNaN(requestSeconds) || Math.abs(nowSeconds - requestSeconds) > 30) { + const requestSeconds = Number.parseInt(timestamp, 10) + if (Number.isNaN(requestSeconds) || Math.abs(nowSeconds - requestSeconds) > 30) { return false } @@ -129,7 +129,9 @@ export const zoomHandler: WebhookProviderHandler = { } if (!secretToken) { - logger.warn(`[${requestId}] No secret token configured for Zoom URL validation on path: ${path}`) + logger.warn( + `[${requestId}] No secret token configured for Zoom URL validation on path: ${path}` + ) return null } From 4fd970a7a05d6f149133e5be9097c424993d3ea4 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:45:07 -0700 Subject: [PATCH 05/10] fix(triggers): harden Zoom webhook security per PR review - verifyAuth now fails closed (401) when secretToken is missing - handleChallenge DB query filters by provider='zoom' to avoid cross-provider leaks - handleChallenge verifies x-zm-signature before responding to prevent HMAC oracle --- apps/sim/lib/webhooks/providers/zoom.ts | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 998602edd3d..b33c56aeffc 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -50,7 +50,10 @@ export const zoomHandler: WebhookProviderHandler = { verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { const secretToken = providerConfig.secretToken as string | undefined if (!secretToken) { - return null + logger.warn( + `[${requestId}] Zoom webhook missing secretToken in providerConfig — rejecting request` + ) + return new NextResponse('Unauthorized - Zoom secret token not configured', { status: 401 }) } const signature = request.headers.get('x-zm-signature') @@ -98,7 +101,7 @@ export const zoomHandler: WebhookProviderHandler = { * Zoom sends an `endpoint.url_validation` event with a `plainToken` that must * be hashed with the app's secret token and returned alongside the original token. */ - async handleChallenge(body: unknown, _request: NextRequest, requestId: string, path: string) { + async handleChallenge(body: unknown, request: NextRequest, requestId: string, path: string) { const obj = body as Record | null if (obj?.event !== 'endpoint.url_validation') { return null @@ -118,7 +121,9 @@ export const zoomHandler: WebhookProviderHandler = { const webhooks = await db .select() .from(webhook) - .where(and(eq(webhook.path, path), eq(webhook.isActive, true))) + .where( + and(eq(webhook.path, path), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true)) + ) if (webhooks.length > 0) { const config = webhooks[0].providerConfig as Record | null secretToken = (config?.secretToken as string) || '' @@ -135,6 +140,17 @@ export const zoomHandler: WebhookProviderHandler = { return null } + // Verify the challenge request's signature to prevent HMAC oracle attacks + const signature = request.headers.get('x-zm-signature') + const timestamp = request.headers.get('x-zm-request-timestamp') + if (signature && timestamp) { + const rawBody = JSON.stringify(body) + if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) { + logger.warn(`[${requestId}] Zoom challenge request failed signature verification`) + return null + } + } + const hashForValidate = crypto .createHmac('sha256', secretToken) .update(plainToken) From bd0a5aa72ae6b33f8fa73da50b89d4ba4bc9ea8a Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:52:02 -0700 Subject: [PATCH 06/10] fix(triggers): rename type to meeting_type to avoid TriggerOutput type collision --- apps/sim/triggers/zoom/utils.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/sim/triggers/zoom/utils.ts b/apps/sim/triggers/zoom/utils.ts index b0ab69bf588..b0c02c6b85b 100644 --- a/apps/sim/triggers/zoom/utils.ts +++ b/apps/sim/triggers/zoom/utils.ts @@ -128,7 +128,10 @@ export function buildMeetingOutputs(): Record { id: { type: 'number', description: 'Meeting ID' }, uuid: { type: 'string', description: 'Meeting UUID' }, topic: { type: 'string', description: 'Meeting topic' }, - meeting_type: { type: 'number', description: 'Meeting type (1=instant, 2=scheduled, etc.)' }, + meeting_type: { + type: 'number', + description: 'Meeting type (1=instant, 2=scheduled, etc.)', + }, host_id: { type: 'string', description: 'Host user ID' }, start_time: { type: 'string', description: 'Meeting start time (ISO 8601)' }, end_time: { From 78bec790bab86f217c493992d951cd33f0ce3d26 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 11:57:44 -0700 Subject: [PATCH 07/10] fix(triggers): make challenge signature verification mandatory, not optional --- apps/sim/lib/webhooks/providers/zoom.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index b33c56aeffc..68ce5eff074 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -143,12 +143,14 @@ export const zoomHandler: WebhookProviderHandler = { // Verify the challenge request's signature to prevent HMAC oracle attacks const signature = request.headers.get('x-zm-signature') const timestamp = request.headers.get('x-zm-request-timestamp') - if (signature && timestamp) { - const rawBody = JSON.stringify(body) - if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) { - logger.warn(`[${requestId}] Zoom challenge request failed signature verification`) - return null - } + if (!signature || !timestamp) { + logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`) + return null + } + const rawBody = JSON.stringify(body) + if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) { + logger.warn(`[${requestId}] Zoom challenge request failed signature verification`) + return null } const hashForValidate = crypto From 6e6aa21616f9a8cb1de8104d50e133a3784c3992 Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 12:19:00 -0700 Subject: [PATCH 08/10] fix(triggers): fail closed on unknown trigger IDs and update Zoom landing page data - isZoomEventMatch now returns false for unrecognized trigger IDs - Update integrations.json with 6 Zoom triggers --- .../integrations/data/integrations.json | 29 +++++++++++++++++-- apps/sim/triggers/zoom/utils.ts | 2 +- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 9bd9ced1f5f..6a1f469426d 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -12886,8 +12886,33 @@ } ], "operationCount": 10, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "name": "Meeting Started", + "description": "Triggered when a Zoom meeting starts" + }, + { + "name": "Meeting Ended", + "description": "Triggered when a Zoom meeting ends" + }, + { + "name": "Participant Joined", + "description": "Triggered when a participant joins a Zoom meeting" + }, + { + "name": "Participant Left", + "description": "Triggered when a participant leaves a Zoom meeting" + }, + { + "name": "Recording Completed", + "description": "Triggered when a Zoom cloud recording is completed" + }, + { + "name": "Generic Webhook", + "description": "Triggered on any Zoom webhook event" + } + ], + "triggerCount": 6, "authType": "oauth", "category": "tools", "integrationType": "communication", diff --git a/apps/sim/triggers/zoom/utils.ts b/apps/sim/triggers/zoom/utils.ts index b0c02c6b85b..20e316f0400 100644 --- a/apps/sim/triggers/zoom/utils.ts +++ b/apps/sim/triggers/zoom/utils.ts @@ -23,7 +23,7 @@ export function isZoomEventMatch(triggerId: string, event: string): boolean { const allowedEvents = ZOOM_TRIGGER_EVENT_MAP[triggerId] if (!allowedEvents) { - return true + return false } return allowedEvents.includes(event) From 1c15918f08105d12c9c2d2daef93c4c77200ac8e Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 12:31:22 -0700 Subject: [PATCH 09/10] fix(triggers): add missing id fields to Zoom trigger entries in integrations.json --- apps/sim/app/(landing)/integrations/data/integrations.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 6a1f469426d..cfc7d0d365b 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -12888,26 +12888,32 @@ "operationCount": 10, "triggers": [ { + "id": "zoom_meeting_started", "name": "Meeting Started", "description": "Triggered when a Zoom meeting starts" }, { + "id": "zoom_meeting_ended", "name": "Meeting Ended", "description": "Triggered when a Zoom meeting ends" }, { + "id": "zoom_participant_joined", "name": "Participant Joined", "description": "Triggered when a participant joins a Zoom meeting" }, { + "id": "zoom_participant_left", "name": "Participant Left", "description": "Triggered when a participant leaves a Zoom meeting" }, { + "id": "zoom_recording_completed", "name": "Recording Completed", "description": "Triggered when a Zoom cloud recording is completed" }, { + "id": "zoom_webhook", "name": "Generic Webhook", "description": "Triggered on any Zoom webhook event" } From 3813931669c8f18df840f7f4fa093f427de76f7b Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Mon, 6 Apr 2026 12:41:58 -0700 Subject: [PATCH 10/10] fix(triggers): increase Zoom timestamp tolerance to 300s per Zoom docs --- apps/sim/lib/webhooks/providers/zoom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts index 68ce5eff074..ed33b2bac2e 100644 --- a/apps/sim/lib/webhooks/providers/zoom.ts +++ b/apps/sim/lib/webhooks/providers/zoom.ts @@ -31,7 +31,7 @@ function validateZoomSignature( const nowSeconds = Math.floor(Date.now() / 1000) const requestSeconds = Number.parseInt(timestamp, 10) - if (Number.isNaN(requestSeconds) || Math.abs(nowSeconds - requestSeconds) > 30) { + if (Number.isNaN(requestSeconds) || Math.abs(nowSeconds - requestSeconds) > 300) { return false }