Skip to content

Commit beee193

Browse files
committed
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.
1 parent 62a7700 commit beee193

File tree

12 files changed

+635
-1
lines changed

12 files changed

+635
-1
lines changed

apps/sim/lib/webhooks/processor.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ export async function parseWebhookBody(
117117
}
118118

119119
/** Providers that implement challenge/verification handling, checked before webhook lookup. */
120-
const CHALLENGE_PROVIDERS = ['slack', 'microsoft-teams', 'whatsapp'] as const
120+
const CHALLENGE_PROVIDERS = ['slack', 'microsoft-teams', 'whatsapp', 'zoom'] as const
121121

122122
export async function handleProviderChallenges(
123123
body: unknown,

apps/sim/lib/webhooks/providers/registry.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types'
3232
import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
3333
import { webflowHandler } from '@/lib/webhooks/providers/webflow'
3434
import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp'
35+
import { zoomHandler } from '@/lib/webhooks/providers/zoom'
3536

3637
const logger = createLogger('WebhookProviderRegistry')
3738

@@ -66,6 +67,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
6667
typeform: typeformHandler,
6768
webflow: webflowHandler,
6869
whatsapp: whatsappHandler,
70+
zoom: zoomHandler,
6971
}
7072

7173
/**
Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
import crypto from 'crypto'
2+
import { db, webhook } from '@sim/db'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq, isNull } from 'drizzle-orm'
5+
import type { NextRequest } from 'next/server'
6+
import { NextResponse } from 'next/server'
7+
import { safeCompare } from '@/lib/core/security/encryption'
8+
import type {
9+
AuthContext,
10+
EventMatchContext,
11+
WebhookProviderHandler,
12+
} from '@/lib/webhooks/providers/types'
13+
14+
const logger = createLogger('WebhookProvider:Zoom')
15+
16+
/**
17+
* Validate Zoom webhook signature using HMAC-SHA256.
18+
* Zoom sends `x-zm-signature` as `v0=<hex>` and `x-zm-request-timestamp`.
19+
* The message to hash is `v0:{timestamp}:{rawBody}`.
20+
*/
21+
function validateZoomSignature(
22+
secretToken: string,
23+
signature: string,
24+
timestamp: string,
25+
body: string
26+
): boolean {
27+
try {
28+
if (!secretToken || !signature || !timestamp || !body) {
29+
return false
30+
}
31+
32+
const message = `v0:${timestamp}:${body}`
33+
const computedHash = crypto.createHmac('sha256', secretToken).update(message).digest('hex')
34+
const expectedSignature = `v0=${computedHash}`
35+
36+
return safeCompare(expectedSignature, signature)
37+
} catch (err) {
38+
logger.error('Zoom signature validation error', err)
39+
return false
40+
}
41+
}
42+
43+
export const zoomHandler: WebhookProviderHandler = {
44+
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
45+
const secretToken = providerConfig.secretToken as string | undefined
46+
if (!secretToken) {
47+
return null
48+
}
49+
50+
const signature = request.headers.get('x-zm-signature')
51+
const timestamp = request.headers.get('x-zm-request-timestamp')
52+
53+
if (!signature || !timestamp) {
54+
logger.warn(`[${requestId}] Zoom webhook missing signature or timestamp header`)
55+
return new NextResponse('Unauthorized - Missing Zoom signature', { status: 401 })
56+
}
57+
58+
if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) {
59+
logger.warn(`[${requestId}] Zoom webhook signature verification failed`)
60+
return new NextResponse('Unauthorized - Invalid Zoom signature', { status: 401 })
61+
}
62+
63+
return null
64+
},
65+
66+
async matchEvent({ webhook: wh, workflow, body, requestId, providerConfig }: EventMatchContext) {
67+
const triggerId = providerConfig.triggerId as string | undefined
68+
const obj = body as Record<string, unknown>
69+
const event = obj.event as string | undefined
70+
71+
if (triggerId) {
72+
const { isZoomEventMatch } = await import('@/triggers/zoom/utils')
73+
if (!isZoomEventMatch(triggerId, event || '')) {
74+
logger.debug(
75+
`[${requestId}] Zoom event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`,
76+
{
77+
webhookId: wh.id,
78+
workflowId: workflow.id,
79+
triggerId,
80+
receivedEvent: event,
81+
}
82+
)
83+
return false
84+
}
85+
}
86+
87+
return true
88+
},
89+
90+
/**
91+
* Handle Zoom endpoint URL validation challenges.
92+
* Zoom sends an `endpoint.url_validation` event with a `plainToken` that must
93+
* be hashed with the app's secret token and returned alongside the original token.
94+
*/
95+
async handleChallenge(body: unknown, _request: NextRequest, requestId: string, path: string) {
96+
const obj = body as Record<string, unknown> | null
97+
if (obj?.event !== 'endpoint.url_validation') {
98+
return null
99+
}
100+
101+
const payload = obj.payload as Record<string, unknown> | undefined
102+
const plainToken = payload?.plainToken as string | undefined
103+
if (!plainToken) {
104+
return null
105+
}
106+
107+
logger.info(`[${requestId}] Zoom URL validation request received for path: ${path}`)
108+
109+
// Look up the webhook record to get the secret token from providerConfig
110+
let secretToken = ''
111+
try {
112+
const webhooks = await db
113+
.select()
114+
.from(webhook)
115+
.where(and(eq(webhook.path, path), isNull(webhook.deletedAt)))
116+
if (webhooks.length > 0) {
117+
const config = webhooks[0].providerConfig as Record<string, unknown> | null
118+
secretToken = (config?.secretToken as string) || ''
119+
}
120+
} catch (err) {
121+
logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err)
122+
}
123+
124+
const hashForValidate = crypto
125+
.createHmac('sha256', secretToken)
126+
.update(plainToken)
127+
.digest('hex')
128+
129+
return NextResponse.json({
130+
plainToken,
131+
encryptedToken: hashForValidate,
132+
})
133+
},
134+
}

apps/sim/triggers/registry.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,14 @@ import {
193193
webflowFormSubmissionTrigger,
194194
} from '@/triggers/webflow'
195195
import { whatsappWebhookTrigger } from '@/triggers/whatsapp'
196+
import {
197+
zoomMeetingEndedTrigger,
198+
zoomMeetingStartedTrigger,
199+
zoomParticipantJoinedTrigger,
200+
zoomParticipantLeftTrigger,
201+
zoomRecordingCompletedTrigger,
202+
zoomWebhookTrigger,
203+
} from '@/triggers/zoom'
196204

197205
export const TRIGGER_REGISTRY: TriggerRegistry = {
198206
slack_webhook: slackWebhookTrigger,
@@ -360,4 +368,10 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
360368
hubspot_ticket_restored: hubspotTicketRestoredTrigger,
361369
hubspot_webhook: hubspotWebhookTrigger,
362370
imap_poller: imapPollingTrigger,
371+
zoom_meeting_started: zoomMeetingStartedTrigger,
372+
zoom_meeting_ended: zoomMeetingEndedTrigger,
373+
zoom_participant_joined: zoomParticipantJoinedTrigger,
374+
zoom_participant_left: zoomParticipantLeftTrigger,
375+
zoom_recording_completed: zoomRecordingCompletedTrigger,
376+
zoom_webhook: zoomWebhookTrigger,
363377
}

apps/sim/triggers/zoom/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
export { zoomMeetingEndedTrigger } from './meeting_ended'
2+
export { zoomMeetingStartedTrigger } from './meeting_started'
3+
export { zoomParticipantJoinedTrigger } from './participant_joined'
4+
export { zoomParticipantLeftTrigger } from './participant_left'
5+
export { zoomRecordingCompletedTrigger } from './recording_completed'
6+
export { zoomWebhookTrigger } from './webhook'
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ZoomIcon } from '@/components/icons'
2+
import { buildTriggerSubBlocks } from '@/triggers'
3+
import type { TriggerConfig } from '@/triggers/types'
4+
import {
5+
buildMeetingOutputs,
6+
zoomSecretTokenField,
7+
zoomSetupInstructions,
8+
zoomTriggerOptions,
9+
} from '@/triggers/zoom/utils'
10+
11+
/**
12+
* Zoom Meeting Ended Trigger
13+
*/
14+
export const zoomMeetingEndedTrigger: TriggerConfig = {
15+
id: 'zoom_meeting_ended',
16+
name: 'Zoom Meeting Ended',
17+
provider: 'zoom',
18+
description: 'Trigger workflow when a Zoom meeting ends',
19+
version: '1.0.0',
20+
icon: ZoomIcon,
21+
22+
subBlocks: buildTriggerSubBlocks({
23+
triggerId: 'zoom_meeting_ended',
24+
triggerOptions: zoomTriggerOptions,
25+
setupInstructions: zoomSetupInstructions('meeting_ended'),
26+
extraFields: [zoomSecretTokenField('zoom_meeting_ended')],
27+
}),
28+
29+
outputs: buildMeetingOutputs(),
30+
31+
webhook: {
32+
method: 'POST',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
},
36+
},
37+
}
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { ZoomIcon } from '@/components/icons'
2+
import { buildTriggerSubBlocks } from '@/triggers'
3+
import type { TriggerConfig } from '@/triggers/types'
4+
import {
5+
buildMeetingOutputs,
6+
zoomSecretTokenField,
7+
zoomSetupInstructions,
8+
zoomTriggerOptions,
9+
} from '@/triggers/zoom/utils'
10+
11+
/**
12+
* Zoom Meeting Started Trigger
13+
*
14+
* Primary trigger - includes the dropdown for selecting trigger type.
15+
*/
16+
export const zoomMeetingStartedTrigger: TriggerConfig = {
17+
id: 'zoom_meeting_started',
18+
name: 'Zoom Meeting Started',
19+
provider: 'zoom',
20+
description: 'Trigger workflow when a Zoom meeting starts',
21+
version: '1.0.0',
22+
icon: ZoomIcon,
23+
24+
subBlocks: buildTriggerSubBlocks({
25+
triggerId: 'zoom_meeting_started',
26+
triggerOptions: zoomTriggerOptions,
27+
includeDropdown: true,
28+
setupInstructions: zoomSetupInstructions('meeting_started'),
29+
extraFields: [zoomSecretTokenField('zoom_meeting_started')],
30+
}),
31+
32+
outputs: buildMeetingOutputs(),
33+
34+
webhook: {
35+
method: 'POST',
36+
headers: {
37+
'Content-Type': 'application/json',
38+
},
39+
},
40+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ZoomIcon } from '@/components/icons'
2+
import { buildTriggerSubBlocks } from '@/triggers'
3+
import type { TriggerConfig } from '@/triggers/types'
4+
import {
5+
buildParticipantOutputs,
6+
zoomSecretTokenField,
7+
zoomSetupInstructions,
8+
zoomTriggerOptions,
9+
} from '@/triggers/zoom/utils'
10+
11+
/**
12+
* Zoom Participant Joined Trigger
13+
*/
14+
export const zoomParticipantJoinedTrigger: TriggerConfig = {
15+
id: 'zoom_participant_joined',
16+
name: 'Zoom Participant Joined',
17+
provider: 'zoom',
18+
description: 'Trigger workflow when a participant joins a Zoom meeting',
19+
version: '1.0.0',
20+
icon: ZoomIcon,
21+
22+
subBlocks: buildTriggerSubBlocks({
23+
triggerId: 'zoom_participant_joined',
24+
triggerOptions: zoomTriggerOptions,
25+
setupInstructions: zoomSetupInstructions('participant_joined'),
26+
extraFields: [zoomSecretTokenField('zoom_participant_joined')],
27+
}),
28+
29+
outputs: buildParticipantOutputs(),
30+
31+
webhook: {
32+
method: 'POST',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
},
36+
},
37+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ZoomIcon } from '@/components/icons'
2+
import { buildTriggerSubBlocks } from '@/triggers'
3+
import type { TriggerConfig } from '@/triggers/types'
4+
import {
5+
buildParticipantOutputs,
6+
zoomSecretTokenField,
7+
zoomSetupInstructions,
8+
zoomTriggerOptions,
9+
} from '@/triggers/zoom/utils'
10+
11+
/**
12+
* Zoom Participant Left Trigger
13+
*/
14+
export const zoomParticipantLeftTrigger: TriggerConfig = {
15+
id: 'zoom_participant_left',
16+
name: 'Zoom Participant Left',
17+
provider: 'zoom',
18+
description: 'Trigger workflow when a participant leaves a Zoom meeting',
19+
version: '1.0.0',
20+
icon: ZoomIcon,
21+
22+
subBlocks: buildTriggerSubBlocks({
23+
triggerId: 'zoom_participant_left',
24+
triggerOptions: zoomTriggerOptions,
25+
setupInstructions: zoomSetupInstructions('participant_left'),
26+
extraFields: [zoomSecretTokenField('zoom_participant_left')],
27+
}),
28+
29+
outputs: buildParticipantOutputs(),
30+
31+
webhook: {
32+
method: 'POST',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
},
36+
},
37+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { ZoomIcon } from '@/components/icons'
2+
import { buildTriggerSubBlocks } from '@/triggers'
3+
import type { TriggerConfig } from '@/triggers/types'
4+
import {
5+
buildRecordingOutputs,
6+
zoomSecretTokenField,
7+
zoomSetupInstructions,
8+
zoomTriggerOptions,
9+
} from '@/triggers/zoom/utils'
10+
11+
/**
12+
* Zoom Recording Completed Trigger
13+
*/
14+
export const zoomRecordingCompletedTrigger: TriggerConfig = {
15+
id: 'zoom_recording_completed',
16+
name: 'Zoom Recording Completed',
17+
provider: 'zoom',
18+
description: 'Trigger workflow when a Zoom cloud recording is completed',
19+
version: '1.0.0',
20+
icon: ZoomIcon,
21+
22+
subBlocks: buildTriggerSubBlocks({
23+
triggerId: 'zoom_recording_completed',
24+
triggerOptions: zoomTriggerOptions,
25+
setupInstructions: zoomSetupInstructions('recording_completed'),
26+
extraFields: [zoomSecretTokenField('zoom_recording_completed')],
27+
}),
28+
29+
outputs: buildRecordingOutputs(),
30+
31+
webhook: {
32+
method: 'POST',
33+
headers: {
34+
'Content-Type': 'application/json',
35+
},
36+
},
37+
}

0 commit comments

Comments
 (0)