Skip to content

Commit 18a7868

Browse files
authored
feat(triggers): add Zoom webhook triggers (#3992)
* 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. * fix(triggers): use webhook.isActive instead of non-existent deletedAt column * 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 * lint * 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 * fix(triggers): rename type to meeting_type to avoid TriggerOutput type collision * fix(triggers): make challenge signature verification mandatory, not optional * 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 * fix(triggers): add missing id fields to Zoom trigger entries in integrations.json * fix(triggers): increase Zoom timestamp tolerance to 300s per Zoom docs
1 parent cd5cee3 commit 18a7868

File tree

13 files changed

+782
-6
lines changed

13 files changed

+782
-6
lines changed

apps/sim/app/(landing)/integrations/data/integrations.json

Lines changed: 112 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5528,6 +5528,11 @@
55285528
"name": "HubSpot Contact Deleted",
55295529
"description": "Trigger workflow when a contact is deleted in HubSpot"
55305530
},
5531+
{
5532+
"id": "hubspot_contact_merged",
5533+
"name": "HubSpot Contact Merged",
5534+
"description": "Trigger workflow when contacts are merged in HubSpot"
5535+
},
55315536
{
55325537
"id": "hubspot_contact_privacy_deleted",
55335538
"name": "HubSpot Contact Privacy Deleted",
@@ -5538,6 +5543,11 @@
55385543
"name": "HubSpot Contact Property Changed",
55395544
"description": "Trigger workflow when any property of a contact is updated in HubSpot"
55405545
},
5546+
{
5547+
"id": "hubspot_contact_restored",
5548+
"name": "HubSpot Contact Restored",
5549+
"description": "Trigger workflow when a deleted contact is restored in HubSpot"
5550+
},
55415551
{
55425552
"id": "hubspot_company_created",
55435553
"name": "HubSpot Company Created",
@@ -5548,11 +5558,21 @@
55485558
"name": "HubSpot Company Deleted",
55495559
"description": "Trigger workflow when a company is deleted in HubSpot"
55505560
},
5561+
{
5562+
"id": "hubspot_company_merged",
5563+
"name": "HubSpot Company Merged",
5564+
"description": "Trigger workflow when companies are merged in HubSpot"
5565+
},
55515566
{
55525567
"id": "hubspot_company_property_changed",
55535568
"name": "HubSpot Company Property Changed",
55545569
"description": "Trigger workflow when any property of a company is updated in HubSpot"
55555570
},
5571+
{
5572+
"id": "hubspot_company_restored",
5573+
"name": "HubSpot Company Restored",
5574+
"description": "Trigger workflow when a deleted company is restored in HubSpot"
5575+
},
55565576
{
55575577
"id": "hubspot_conversation_creation",
55585578
"name": "HubSpot Conversation Creation",
@@ -5588,11 +5608,21 @@
55885608
"name": "HubSpot Deal Deleted",
55895609
"description": "Trigger workflow when a deal is deleted in HubSpot"
55905610
},
5611+
{
5612+
"id": "hubspot_deal_merged",
5613+
"name": "HubSpot Deal Merged",
5614+
"description": "Trigger workflow when deals are merged in HubSpot"
5615+
},
55915616
{
55925617
"id": "hubspot_deal_property_changed",
55935618
"name": "HubSpot Deal Property Changed",
55945619
"description": "Trigger workflow when any property of a deal is updated in HubSpot"
55955620
},
5621+
{
5622+
"id": "hubspot_deal_restored",
5623+
"name": "HubSpot Deal Restored",
5624+
"description": "Trigger workflow when a deleted deal is restored in HubSpot"
5625+
},
55965626
{
55975627
"id": "hubspot_ticket_created",
55985628
"name": "HubSpot Ticket Created",
@@ -5603,13 +5633,28 @@
56035633
"name": "HubSpot Ticket Deleted",
56045634
"description": "Trigger workflow when a ticket is deleted in HubSpot"
56055635
},
5636+
{
5637+
"id": "hubspot_ticket_merged",
5638+
"name": "HubSpot Ticket Merged",
5639+
"description": "Trigger workflow when tickets are merged in HubSpot"
5640+
},
56065641
{
56075642
"id": "hubspot_ticket_property_changed",
56085643
"name": "HubSpot Ticket Property Changed",
56095644
"description": "Trigger workflow when any property of a ticket is updated in HubSpot"
5645+
},
5646+
{
5647+
"id": "hubspot_ticket_restored",
5648+
"name": "HubSpot Ticket Restored",
5649+
"description": "Trigger workflow when a deleted ticket is restored in HubSpot"
5650+
},
5651+
{
5652+
"id": "hubspot_webhook",
5653+
"name": "HubSpot Webhook (All Events)",
5654+
"description": "Trigger workflow on any HubSpot webhook event"
56105655
}
56115656
],
5612-
"triggerCount": 18,
5657+
"triggerCount": 27,
56135658
"authType": "oauth",
56145659
"category": "tools",
56155660
"integrationType": "crm",
@@ -10263,8 +10308,39 @@
1026310308
}
1026410309
],
1026510310
"operationCount": 35,
10266-
"triggers": [],
10267-
"triggerCount": 0,
10311+
"triggers": [
10312+
{
10313+
"id": "salesforce_record_created",
10314+
"name": "Salesforce Record Created",
10315+
"description": "Trigger workflow when a Salesforce record is created"
10316+
},
10317+
{
10318+
"id": "salesforce_record_updated",
10319+
"name": "Salesforce Record Updated",
10320+
"description": "Trigger workflow when a Salesforce record is updated"
10321+
},
10322+
{
10323+
"id": "salesforce_record_deleted",
10324+
"name": "Salesforce Record Deleted",
10325+
"description": "Trigger workflow when a Salesforce record is deleted"
10326+
},
10327+
{
10328+
"id": "salesforce_opportunity_stage_changed",
10329+
"name": "Salesforce Opportunity Stage Changed",
10330+
"description": "Trigger workflow when an opportunity stage changes"
10331+
},
10332+
{
10333+
"id": "salesforce_case_status_changed",
10334+
"name": "Salesforce Case Status Changed",
10335+
"description": "Trigger workflow when a case status changes"
10336+
},
10337+
{
10338+
"id": "salesforce_webhook",
10339+
"name": "Salesforce Webhook (All Events)",
10340+
"description": "Trigger workflow on any Salesforce webhook event"
10341+
}
10342+
],
10343+
"triggerCount": 6,
1026810344
"authType": "oauth",
1026910345
"category": "tools",
1027010346
"integrationType": "crm",
@@ -12856,8 +12932,39 @@
1285612932
}
1285712933
],
1285812934
"operationCount": 10,
12859-
"triggers": [],
12860-
"triggerCount": 0,
12935+
"triggers": [
12936+
{
12937+
"id": "zoom_meeting_started",
12938+
"name": "Meeting Started",
12939+
"description": "Triggered when a Zoom meeting starts"
12940+
},
12941+
{
12942+
"id": "zoom_meeting_ended",
12943+
"name": "Meeting Ended",
12944+
"description": "Triggered when a Zoom meeting ends"
12945+
},
12946+
{
12947+
"id": "zoom_participant_joined",
12948+
"name": "Participant Joined",
12949+
"description": "Triggered when a participant joins a Zoom meeting"
12950+
},
12951+
{
12952+
"id": "zoom_participant_left",
12953+
"name": "Participant Left",
12954+
"description": "Triggered when a participant leaves a Zoom meeting"
12955+
},
12956+
{
12957+
"id": "zoom_recording_completed",
12958+
"name": "Recording Completed",
12959+
"description": "Triggered when a Zoom cloud recording is completed"
12960+
},
12961+
{
12962+
"id": "zoom_webhook",
12963+
"name": "Generic Webhook",
12964+
"description": "Triggered on any Zoom webhook event"
12965+
}
12966+
],
12967+
"triggerCount": 6,
1286112968
"authType": "oauth",
1286212969
"category": "tools",
1286312970
"integrationType": "communication",

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
@@ -38,6 +38,7 @@ import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
3838
import { vercelHandler } from '@/lib/webhooks/providers/vercel'
3939
import { webflowHandler } from '@/lib/webhooks/providers/webflow'
4040
import { whatsappHandler } from '@/lib/webhooks/providers/whatsapp'
41+
import { zoomHandler } from '@/lib/webhooks/providers/zoom'
4142

4243
const logger = createLogger('WebhookProviderRegistry')
4344

@@ -78,6 +79,7 @@ const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
7879
vercel: vercelHandler,
7980
webflow: webflowHandler,
8081
whatsapp: whatsappHandler,
82+
zoom: zoomHandler,
8183
}
8284

8385
/**
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import crypto from 'crypto'
2+
import { db, webhook } from '@sim/db'
3+
import { createLogger } from '@sim/logger'
4+
import { and, eq } 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 nowSeconds = Math.floor(Date.now() / 1000)
33+
const requestSeconds = Number.parseInt(timestamp, 10)
34+
if (Number.isNaN(requestSeconds) || Math.abs(nowSeconds - requestSeconds) > 300) {
35+
return false
36+
}
37+
38+
const message = `v0:${timestamp}:${body}`
39+
const computedHash = crypto.createHmac('sha256', secretToken).update(message).digest('hex')
40+
const expectedSignature = `v0=${computedHash}`
41+
42+
return safeCompare(expectedSignature, signature)
43+
} catch (err) {
44+
logger.error('Zoom signature validation error', err)
45+
return false
46+
}
47+
}
48+
49+
export const zoomHandler: WebhookProviderHandler = {
50+
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
51+
const secretToken = providerConfig.secretToken as string | undefined
52+
if (!secretToken) {
53+
logger.warn(
54+
`[${requestId}] Zoom webhook missing secretToken in providerConfig — rejecting request`
55+
)
56+
return new NextResponse('Unauthorized - Zoom secret token not configured', { status: 401 })
57+
}
58+
59+
const signature = request.headers.get('x-zm-signature')
60+
const timestamp = request.headers.get('x-zm-request-timestamp')
61+
62+
if (!signature || !timestamp) {
63+
logger.warn(`[${requestId}] Zoom webhook missing signature or timestamp header`)
64+
return new NextResponse('Unauthorized - Missing Zoom signature', { status: 401 })
65+
}
66+
67+
if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) {
68+
logger.warn(`[${requestId}] Zoom webhook signature verification failed`)
69+
return new NextResponse('Unauthorized - Invalid Zoom signature', { status: 401 })
70+
}
71+
72+
return null
73+
},
74+
75+
async matchEvent({ webhook: wh, workflow, body, requestId, providerConfig }: EventMatchContext) {
76+
const triggerId = providerConfig.triggerId as string | undefined
77+
const obj = body as Record<string, unknown>
78+
const event = obj.event as string | undefined
79+
80+
if (triggerId) {
81+
const { isZoomEventMatch } = await import('@/triggers/zoom/utils')
82+
if (!isZoomEventMatch(triggerId, event || '')) {
83+
logger.debug(
84+
`[${requestId}] Zoom event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`,
85+
{
86+
webhookId: wh.id,
87+
workflowId: workflow.id,
88+
triggerId,
89+
receivedEvent: event,
90+
}
91+
)
92+
return false
93+
}
94+
}
95+
96+
return true
97+
},
98+
99+
/**
100+
* Handle Zoom endpoint URL validation challenges.
101+
* Zoom sends an `endpoint.url_validation` event with a `plainToken` that must
102+
* be hashed with the app's secret token and returned alongside the original token.
103+
*/
104+
async handleChallenge(body: unknown, request: NextRequest, requestId: string, path: string) {
105+
const obj = body as Record<string, unknown> | null
106+
if (obj?.event !== 'endpoint.url_validation') {
107+
return null
108+
}
109+
110+
const payload = obj.payload as Record<string, unknown> | undefined
111+
const plainToken = payload?.plainToken as string | undefined
112+
if (!plainToken) {
113+
return null
114+
}
115+
116+
logger.info(`[${requestId}] Zoom URL validation request received for path: ${path}`)
117+
118+
// Look up the webhook record to get the secret token from providerConfig
119+
let secretToken = ''
120+
try {
121+
const webhooks = await db
122+
.select()
123+
.from(webhook)
124+
.where(
125+
and(eq(webhook.path, path), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true))
126+
)
127+
if (webhooks.length > 0) {
128+
const config = webhooks[0].providerConfig as Record<string, unknown> | null
129+
secretToken = (config?.secretToken as string) || ''
130+
}
131+
} catch (err) {
132+
logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err)
133+
return null
134+
}
135+
136+
if (!secretToken) {
137+
logger.warn(
138+
`[${requestId}] No secret token configured for Zoom URL validation on path: ${path}`
139+
)
140+
return null
141+
}
142+
143+
// Verify the challenge request's signature to prevent HMAC oracle attacks
144+
const signature = request.headers.get('x-zm-signature')
145+
const timestamp = request.headers.get('x-zm-request-timestamp')
146+
if (!signature || !timestamp) {
147+
logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`)
148+
return null
149+
}
150+
const rawBody = JSON.stringify(body)
151+
if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) {
152+
logger.warn(`[${requestId}] Zoom challenge request failed signature verification`)
153+
return null
154+
}
155+
156+
const hashForValidate = crypto
157+
.createHmac('sha256', secretToken)
158+
.update(plainToken)
159+
.digest('hex')
160+
161+
return NextResponse.json({
162+
plainToken,
163+
encryptedToken: hashForValidate,
164+
})
165+
},
166+
}

apps/sim/triggers/registry.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,14 @@ import {
243243
webflowFormSubmissionTrigger,
244244
} from '@/triggers/webflow'
245245
import { whatsappWebhookTrigger } from '@/triggers/whatsapp'
246+
import {
247+
zoomMeetingEndedTrigger,
248+
zoomMeetingStartedTrigger,
249+
zoomParticipantJoinedTrigger,
250+
zoomParticipantLeftTrigger,
251+
zoomRecordingCompletedTrigger,
252+
zoomWebhookTrigger,
253+
} from '@/triggers/zoom'
246254

247255
export const TRIGGER_REGISTRY: TriggerRegistry = {
248256
slack_webhook: slackWebhookTrigger,
@@ -451,4 +459,10 @@ export const TRIGGER_REGISTRY: TriggerRegistry = {
451459
intercom_contact_created: intercomContactCreatedTrigger,
452460
intercom_user_created: intercomUserCreatedTrigger,
453461
intercom_webhook: intercomWebhookTrigger,
462+
zoom_meeting_started: zoomMeetingStartedTrigger,
463+
zoom_meeting_ended: zoomMeetingEndedTrigger,
464+
zoom_participant_joined: zoomParticipantJoinedTrigger,
465+
zoom_participant_left: zoomParticipantLeftTrigger,
466+
zoom_recording_completed: zoomRecordingCompletedTrigger,
467+
zoom_webhook: zoomWebhookTrigger,
454468
}

0 commit comments

Comments
 (0)