Uh oh!
There was an error while loading. Please reload this page.
- Notifications
You must be signed in to change notification settings - Fork 3.8k
feat(triggers): add Zoom webhook triggers#3992
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Uh oh!
There was an error while loading. Please reload this page.
Changes from all commits
0334c762720425db37b6e9901a774fd970abd0a5aa78bec796e6aa211c159183813931File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading. Please reload this page.
Jump to
Uh oh!
There was an error while loading. Please reload this page.
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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", | ||
| @@ -12810,8 +12886,39 @@ | ||
| } | ||
| ], | ||
| "operationCount": 10, | ||
| "triggers": [], | ||
| "triggerCount": 0, | ||
| "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" | ||
| } | ||
| ], | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| "triggerCount": 6, | ||
| "authType": "oauth", | ||
| "category": "tools", | ||
| "integrationType": "communication", | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,166 @@ | ||
| import crypto from 'crypto' | ||
| import { db, webhook } from '@sim/db' | ||
| import { createLogger } from '@sim/logger' | ||
| import { and, eq } 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 nowSeconds = Math.floor(Date.now() / 1000) | ||
| const requestSeconds = Number.parseInt(timestamp, 10) | ||
| if (Number.isNaN(requestSeconds) || Math.abs(nowSeconds - requestSeconds) > 300) { | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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 | ||
| } | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| export const zoomHandler: WebhookProviderHandler = { | ||
| verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { | ||
| const secretToken = providerConfig.secretToken as string | undefined | ||
| if (!secretToken) { | ||
| logger.warn( | ||
| `[${requestId}] Zoom webhook missing secretToken in providerConfig — rejecting request` | ||
| ) | ||
| return new NextResponse('Unauthorized - Zoom secret token not configured', { status: 401 }) | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true)) | ||
| ) | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| 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) | ||
| return null | ||
| } | ||
| if (!secretToken) { | ||
| logger.warn( | ||
| `[${requestId}] No secret token configured for Zoom URL validation on path: ${path}` | ||
| ) | ||
| 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) { | ||
| logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`) | ||
| return null | ||
| } | ||
| const rawBody = JSON.stringify(body) | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) { | ||
| logger.warn(`[${requestId}] Zoom challenge request failed signature verification`) | ||
| return null | ||
| } | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| const hashForValidate = crypto | ||
| .createHmac('sha256', secretToken) | ||
| .update(plainToken) | ||
| .digest('hex') | ||
| return NextResponse.json({ | ||
| plainToken, | ||
| encryptedToken: hashForValidate, | ||
| }) | ||
| }, | ||
waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. waleedlatif1 marked this conversation as resolved.
Uh oh!There was an error while loading. Please reload this page. | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.