Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
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.
  • Loading branch information
waleedlatif1 committed Apr 6, 2026
commit 0334c76036746f325890ddfa286d0ced1b987586
2 changes: 1 addition & 1 deletion apps/sim/lib/webhooks/processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/lib/webhooks/providers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -72,6 +73,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
typeform: typeformHandler,
webflow: webflowHandler,
whatsapp: whatsappHandler,
zoom: zoomHandler,
}

/**
Expand Down
134 changes: 134 additions & 0 deletions apps/sim/lib/webhooks/providers/zoom.ts
Original file line number Diff line number Diff line change
@@ -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=<hex>` 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
}
}
Comment thread
waleedlatif1 marked this conversation as resolved.

export const zoomHandler: WebhookProviderHandler = {
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const secretToken = providerConfig.secretToken as string | undefined
if (!secretToken) {
return null
}
Comment thread
waleedlatif1 marked this conversation as resolved.

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<string, unknown>
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<string, unknown> | null
if (obj?.event !== 'endpoint.url_validation') {
return null
}

const payload = obj.payload as Record<string, unknown> | 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<string, unknown> | null
secretToken = (config?.secretToken as string) || ''
}
} catch (err) {
logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err)
}
Comment thread
waleedlatif1 marked this conversation as resolved.

const hashForValidate = crypto
.createHmac('sha256', secretToken)
.update(plainToken)
.digest('hex')

return NextResponse.json({
plainToken,
encryptedToken: hashForValidate,
})
},
Comment thread
waleedlatif1 marked this conversation as resolved.
Comment thread
waleedlatif1 marked this conversation as resolved.
}
14 changes: 14 additions & 0 deletions apps/sim/triggers/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
6 changes: 6 additions & 0 deletions apps/sim/triggers/zoom/index.ts
Original file line number Diff line number Diff line change
@@ -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'
37 changes: 37 additions & 0 deletions apps/sim/triggers/zoom/meeting_ended.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
}
40 changes: 40 additions & 0 deletions apps/sim/triggers/zoom/meeting_started.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
}
37 changes: 37 additions & 0 deletions apps/sim/triggers/zoom/participant_joined.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
}
37 changes: 37 additions & 0 deletions apps/sim/triggers/zoom/participant_left.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
}
37 changes: 37 additions & 0 deletions apps/sim/triggers/zoom/recording_completed.ts
Original file line number Diff line number Diff line change
@@ -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',
},
},
}
Loading