From 5ca66c381b7af00ac81adc0fe92a8848928ffa6f Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 00:06:13 -0700 Subject: [PATCH 01/32] refactor(webhooks): extract provider-specific logic into handler registry (#3973) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(webhooks): extract provider-specific logic into handler registry * fix(webhooks): address PR review feedback - Restore original fall-through behavior for generic requireAuth with no token - Replace `any` params with proper types in processor helper functions - Restore array-aware initializer in processTriggerFileOutputs * fix(webhooks): fix build error from union type indexing in processTriggerFileOutputs Cast array initializer to Record to allow string indexing while preserving array runtime semantics for the return value. * fix(webhooks): return 401 when requireAuth is true but no token configured If a user explicitly sets requireAuth: true, they expect auth to be enforced. Returning 401 when no token is configured is the correct behavior — this is an intentional improvement over the original code which silently allowed unauthenticated access in this case. * refactor(webhooks): move signature validators into provider handler files Co-locate each validate*Signature function with its provider handler, eliminating the circular dependency where handlers imported back from utils.server.ts. validateJiraSignature is exported from jira.ts for shared use by confluence.ts. * refactor(webhooks): move challenge handlers into provider files Move handleWhatsAppVerification to providers/whatsapp.ts and handleSlackChallenge to providers/slack.ts. Update processor.ts imports to point to provider files. * refactor(webhooks): move fetchAndProcessAirtablePayloads into airtable handler Co-locate the ~400-line Airtable payload processing function with its provider handler. Remove AirtableChange interface from utils.server.ts. * refactor(webhooks): extract polling config functions into polling-config.ts Move configureGmailPolling, configureOutlookPolling, configureRssPolling, and configureImapPolling out of utils.server.ts into a dedicated module. Update imports in deploy.ts and webhooks/route.ts. * refactor(webhooks): decompose formatWebhookInput into per-provider formatInput methods Move all provider-specific input formatting from the monolithic formatWebhookInput switch statement into each provider's handler file. Delete formatWebhookInput and all its helper functions (fetchWithDNSPinning, formatTeamsGraphNotification, Slack file helpers, convertSquareBracketsToTwiML) from utils.server.ts. Create new handler files for gmail, outlook, rss, imap, and calendly providers. Update webhook-execution.ts to use handler.formatInput as the primary path with raw body passthrough as fallback. utils.server.ts reduced from ~1600 lines to ~370 lines containing only credential-sync functions. Co-Authored-By: Claude Opus 4.6 * refactor(webhooks): decompose provider-subscriptions into handler registry pattern Move all provider-specific subscription create/delete logic from the monolithic provider-subscriptions.ts into individual provider handler files via new createSubscription/deleteSubscription methods on WebhookProviderHandler. Replace the two massive if-else dispatch chains (11 branches each) with simple registry lookups via getProviderHandler(). provider-subscriptions.ts reduced from 2,337 lines to 128 lines (orchestration only). Also migrate polling configuration (gmail, outlook, rss, imap) into provider handlers via configurePolling() method, and challenge/verification handling (slack, whatsapp, teams) via handleChallenge() method. Delete polling-config.ts. Create new handler files for fathom and lemlist providers. Extract shared subscription utilities into subscription-utils.ts. Co-Authored-By: Claude Opus 4.6 * fix(webhooks): fix attio build error, restore imap field, remove demarcation comments - Cast `body` to `Record` in attio formatInput to fix type error with extractor functions - Restore `rejectUnauthorized` field in imap configurePolling for parity - Remove `// ---` section demarcation comments from route.ts and airtable.ts - Update add-trigger skill to reflect handler-based architecture Co-Authored-By: Claude Opus 4.6 * fix(webhooks): remove unused imports from utils.server.ts after rebase Co-Authored-By: Claude Opus 4.6 * fix(webhooks): remove duplicate generic file processing from webhook-execution The generic provider's processInputFiles handler already handles file[] field processing via the handler.processInputFiles call. The hardcoded block from staging was incorrectly preserved during rebase, causing double processing. Co-Authored-By: Claude Opus 4.6 * fix(webhooks): validate auth token is set when requireAuth is enabled at deploy time Rejects deployment with a clear error message if a generic webhook trigger has requireAuth enabled but no authentication token configured, rather than letting requests fail with 401 at runtime. Co-Authored-By: Claude Opus 4.6 * fix(webhooks): remove unintended rejectUnauthorized field from IMAP polling config The refactored IMAP handler added a rejectUnauthorized field that was not present in the original configureImapPolling function. This would default to true for all existing IMAP webhooks, potentially breaking connections to servers with self-signed certificates. Co-Authored-By: Claude Opus 4.6 * fix(webhooks): replace crypto.randomUUID() with generateId() in ashby handler Per project coding standards, use generateId() from @/lib/core/utils/uuid instead of crypto.randomUUID() directly. Co-Authored-By: Claude Opus 4.6 * refactor(webhooks): standardize logger names and remove any types from providers - Standardize logger names to WebhookProvider:X pattern across 6 providers (fathom, gmail, imap, lemlist, outlook, rss) - Replace all `any` types in airtable handler with proper types: - Add AirtableTableChanges interface for API response typing - Change function params from `any` to `Record` - Change AirtableChange fields from Record to Record - Change all catch blocks from `error: any` to `error: unknown` - Change input object from `any` to `Record` Co-Authored-By: Claude Opus 4.6 * refactor(webhooks): remove remaining any types from deploy.ts Replace 3 `catch (error: any)` with `catch (error: unknown)` and 1 `Record` with `Record`. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .claude/commands/add-trigger.md | 472 +-- apps/sim/app/api/webhooks/route.ts | 150 +- .../api/webhooks/trigger/[path]/route.test.ts | 4 - .../app/api/webhooks/trigger/[path]/route.ts | 2 +- apps/sim/background/webhook-execution.ts | 406 +-- apps/sim/lib/core/idempotency/service.ts | 2 +- apps/sim/lib/webhooks/deploy.ts | 68 +- apps/sim/lib/webhooks/processor.test.ts | 15 +- apps/sim/lib/webhooks/processor.ts | 965 +------ .../lib/webhooks/provider-subscriptions.ts | 2298 +-------------- apps/sim/lib/webhooks/provider-utils.ts | 93 - apps/sim/lib/webhooks/providers/airtable.ts | 760 +++++ apps/sim/lib/webhooks/providers/ashby.ts | 208 ++ apps/sim/lib/webhooks/providers/attio.ts | 366 +++ apps/sim/lib/webhooks/providers/calcom.ts | 47 + apps/sim/lib/webhooks/providers/calendly.ts | 211 ++ apps/sim/lib/webhooks/providers/circleback.ts | 67 + apps/sim/lib/webhooks/providers/confluence.ts | 92 + apps/sim/lib/webhooks/providers/fathom.ts | 173 ++ apps/sim/lib/webhooks/providers/fireflies.ts | 63 + apps/sim/lib/webhooks/providers/generic.ts | 145 + apps/sim/lib/webhooks/providers/github.ts | 124 + apps/sim/lib/webhooks/providers/gmail.ts | 117 + .../lib/webhooks/providers/google-forms.ts | 60 + apps/sim/lib/webhooks/providers/grain.ts | 251 ++ apps/sim/lib/webhooks/providers/hubspot.ts | 75 + apps/sim/lib/webhooks/providers/imap.ts | 84 + apps/sim/lib/webhooks/providers/index.ts | 24 + apps/sim/lib/webhooks/providers/jira.ts | 104 + apps/sim/lib/webhooks/providers/lemlist.ts | 218 ++ apps/sim/lib/webhooks/providers/linear.ts | 71 + .../lib/webhooks/providers/microsoft-teams.ts | 787 +++++ apps/sim/lib/webhooks/providers/outlook.ts | 113 + apps/sim/lib/webhooks/providers/registry.ts | 91 + apps/sim/lib/webhooks/providers/rss.ts | 65 + apps/sim/lib/webhooks/providers/slack.ts | 282 ++ apps/sim/lib/webhooks/providers/stripe.ts | 28 + .../webhooks/providers/subscription-utils.ts | 39 + apps/sim/lib/webhooks/providers/telegram.ts | 205 ++ .../lib/webhooks/providers/twilio-voice.ts | 214 ++ apps/sim/lib/webhooks/providers/twilio.ts | 8 + apps/sim/lib/webhooks/providers/typeform.ts | 213 ++ apps/sim/lib/webhooks/providers/types.ts | 143 + apps/sim/lib/webhooks/providers/utils.ts | 102 + apps/sim/lib/webhooks/providers/webflow.ts | 307 ++ apps/sim/lib/webhooks/providers/whatsapp.ts | 118 + apps/sim/lib/webhooks/utils.server.ts | 2572 +---------------- 47 files changed, 6620 insertions(+), 6402 deletions(-) delete mode 100644 apps/sim/lib/webhooks/provider-utils.ts create mode 100644 apps/sim/lib/webhooks/providers/airtable.ts create mode 100644 apps/sim/lib/webhooks/providers/ashby.ts create mode 100644 apps/sim/lib/webhooks/providers/attio.ts create mode 100644 apps/sim/lib/webhooks/providers/calcom.ts create mode 100644 apps/sim/lib/webhooks/providers/calendly.ts create mode 100644 apps/sim/lib/webhooks/providers/circleback.ts create mode 100644 apps/sim/lib/webhooks/providers/confluence.ts create mode 100644 apps/sim/lib/webhooks/providers/fathom.ts create mode 100644 apps/sim/lib/webhooks/providers/fireflies.ts create mode 100644 apps/sim/lib/webhooks/providers/generic.ts create mode 100644 apps/sim/lib/webhooks/providers/github.ts create mode 100644 apps/sim/lib/webhooks/providers/gmail.ts create mode 100644 apps/sim/lib/webhooks/providers/google-forms.ts create mode 100644 apps/sim/lib/webhooks/providers/grain.ts create mode 100644 apps/sim/lib/webhooks/providers/hubspot.ts create mode 100644 apps/sim/lib/webhooks/providers/imap.ts create mode 100644 apps/sim/lib/webhooks/providers/index.ts create mode 100644 apps/sim/lib/webhooks/providers/jira.ts create mode 100644 apps/sim/lib/webhooks/providers/lemlist.ts create mode 100644 apps/sim/lib/webhooks/providers/linear.ts create mode 100644 apps/sim/lib/webhooks/providers/microsoft-teams.ts create mode 100644 apps/sim/lib/webhooks/providers/outlook.ts create mode 100644 apps/sim/lib/webhooks/providers/registry.ts create mode 100644 apps/sim/lib/webhooks/providers/rss.ts create mode 100644 apps/sim/lib/webhooks/providers/slack.ts create mode 100644 apps/sim/lib/webhooks/providers/stripe.ts create mode 100644 apps/sim/lib/webhooks/providers/subscription-utils.ts create mode 100644 apps/sim/lib/webhooks/providers/telegram.ts create mode 100644 apps/sim/lib/webhooks/providers/twilio-voice.ts create mode 100644 apps/sim/lib/webhooks/providers/twilio.ts create mode 100644 apps/sim/lib/webhooks/providers/typeform.ts create mode 100644 apps/sim/lib/webhooks/providers/types.ts create mode 100644 apps/sim/lib/webhooks/providers/utils.ts create mode 100644 apps/sim/lib/webhooks/providers/webflow.ts create mode 100644 apps/sim/lib/webhooks/providers/whatsapp.ts diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md index d252bf61666..d53e1bb609f 100644 --- a/.claude/commands/add-trigger.md +++ b/.claude/commands/add-trigger.md @@ -275,13 +275,15 @@ export const {Service}Block: BlockConfig = { If the service's API supports programmatic webhook creation, implement automatic webhook registration instead of requiring users to manually configure webhooks. This provides a much better user experience. +All subscription lifecycle logic lives on the provider handler — **no code touches `route.ts` or `provider-subscriptions.ts`**. + ### When to Use Automatic Registration Check the service's API documentation for endpoints like: - `POST /webhooks` or `POST /hooks` - Create webhook - `DELETE /webhooks/{id}` - Delete webhook -Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc. +Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, Ashby, Attio, etc. ### Implementation Steps @@ -337,188 +339,145 @@ export function {service}SetupInstructions(eventType: string): string { } ``` -#### 3. Add Webhook Creation to API Route +#### 3. Add `createSubscription` and `deleteSubscription` to the Provider Handler -In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save: +In `apps/sim/lib/webhooks/providers/{service}.ts`, add both lifecycle methods to your handler. The orchestration layer (`provider-subscriptions.ts`, `deploy.ts`, `route.ts`) calls these automatically — you never touch those files. ```typescript -// --- {Service} specific logic --- -if (savedWebhook && provider === '{service}') { - logger.info(`[${requestId}] {Service} provider detected. Creating webhook subscription.`) - try { - const result = await create{Service}WebhookSubscription( - { - id: savedWebhook.id, - path: savedWebhook.path, - providerConfig: savedWebhook.providerConfig, - }, - requestId - ) - - if (result) { - // Update the webhook record with the external webhook ID - const updatedConfig = { - ...(savedWebhook.providerConfig as Record), - externalId: result.id, +import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:{Service}') + +export const {service}Handler: WebhookProviderHandler = { + // ... other methods (verifyAuth, formatInput, etc.) ... + + async createSubscription(ctx: SubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + + if (!apiKey) { + throw new Error('{Service} API Key is required.') } - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, - updatedAt: new Date(), - }) - .where(eq(webhook.id, savedWebhook.id)) - - savedWebhook.providerConfig = updatedConfig - logger.info(`[${requestId}] Successfully created {Service} webhook`, { - externalHookId: result.id, - webhookId: savedWebhook.id, - }) - } - } catch (err) { - logger.error( - `[${requestId}] Error creating {Service} webhook subscription, rolling back webhook`, - err - ) - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return NextResponse.json( - { - error: 'Failed to create webhook in {Service}', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } -} -// --- End {Service} specific logic --- -``` - -Then add the helper function at the end of the file: -```typescript -async function create{Service}WebhookSubscription( - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, projectId } = providerConfig || {} + // Map trigger IDs to service event types + const eventTypeMap: Record = { + {service}_event_a: 'eventA', + {service}_event_b: 'eventB', + {service}_webhook: undefined, // Generic - no filter + } - if (!apiKey) { - throw new Error('{Service} API Key is required.') - } + const eventType = eventTypeMap[triggerId ?? ''] + const notificationUrl = getNotificationUrl(ctx.webhook) - // Map trigger IDs to service event types - const eventTypeMap: Record = { - {service}_event_a: 'eventA', - {service}_event_b: 'eventB', - {service}_webhook: undefined, // Generic - no filter - } + const requestBody: Record = { + url: notificationUrl, + } + if (eventType) { + requestBody.eventType = eventType + } - const eventType = eventTypeMap[triggerId] - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + const response = await fetch('https://api.{service}.com/webhooks', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) - const requestBody: Record = { - url: notificationUrl, - } + const responseBody = (await response.json()) as Record + + if (!response.ok) { + const errorMessage = (responseBody.message as string) || 'Unknown API error' + let userFriendlyMessage = 'Failed to create webhook in {Service}' + if (response.status === 401) { + userFriendlyMessage = 'Invalid API Key. Please verify and try again.' + } else if (errorMessage) { + userFriendlyMessage = `{Service} error: ${errorMessage}` + } + throw new Error(userFriendlyMessage) + } - if (eventType) { - requestBody.eventType = eventType - } + const externalId = responseBody.id as string | undefined + if (!externalId) { + throw new Error('{Service} webhook created but no ID was returned.') + } - if (projectId) { - requestBody.projectId = projectId + logger.info(`[${ctx.requestId}] Created {Service} webhook ${externalId}`) + return { providerConfigUpdates: { externalId } } + } catch (error: unknown) { + const err = error as Error + logger.error(`[${ctx.requestId}] {Service} webhook creation failed`, { + message: err.message, + }) + throw error } + }, - const response = await fetch('https://api.{service}.com/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined - const responseBody = await response.json() + if (!apiKey || !externalId) { + logger.warn(`[${ctx.requestId}] Missing apiKey or externalId, skipping cleanup`) + return + } - if (!response.ok) { - const errorMessage = responseBody.message || 'Unknown API error' - let userFriendlyMessage = 'Failed to create webhook in {Service}' + const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${apiKey}` }, + }) - if (response.status === 401) { - userFriendlyMessage = 'Invalid API Key. Please verify and try again.' - } else if (errorMessage) { - userFriendlyMessage = `{Service} error: ${errorMessage}` + if (!response.ok && response.status !== 404) { + logger.warn( + `[${ctx.requestId}] Failed to delete {Service} webhook (non-fatal): ${response.status}` + ) + } else { + logger.info(`[${ctx.requestId}] Successfully deleted {Service} webhook ${externalId}`) } - - throw new Error(userFriendlyMessage) + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting {Service} webhook (non-fatal)`, error) } - - return { id: responseBody.id } - } catch (error: any) { - logger.error(`Exception during {Service} webhook creation`, { error: error.message }) - throw error - } + }, } ``` -#### 4. Add Webhook Deletion to Provider Subscriptions +#### How It Works -In `apps/sim/lib/webhooks/provider-subscriptions.ts`: +The orchestration layer handles everything automatically: -1. Add a logger: -```typescript -const {service}Logger = createLogger('{Service}Webhook') -``` - -2. Add the delete function: -```typescript -export async function delete{Service}Webhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined +1. **Creation**: `provider-subscriptions.ts` → `createExternalWebhookSubscription()` calls `handler.createSubscription()` → merges `providerConfigUpdates` into the saved webhook record. +2. **Deletion**: `provider-subscriptions.ts` → `cleanupExternalWebhook()` calls `handler.deleteSubscription()` → errors are caught and logged non-fatally. +3. **Polling config**: `deploy.ts` → `configurePollingIfNeeded()` calls `handler.configurePolling()` for credential-based providers (Gmail, Outlook, RSS, IMAP). - if (!apiKey || !externalId) { - {service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`) - return - } +You do NOT need to modify any orchestration files. Just implement the methods on your handler. - const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) +#### Shared Utilities for Subscriptions - if (!response.ok && response.status !== 404) { - {service}Logger.warn(`[${requestId}] Failed to delete webhook (non-fatal): ${response.status}`) - } else { - {service}Logger.info(`[${requestId}] Successfully deleted webhook ${externalId}`) - } - } catch (error) { - {service}Logger.warn(`[${requestId}] Error deleting webhook (non-fatal)`, error) - } -} -``` +Import from `@/lib/webhooks/providers/subscription-utils`: -3. Add to `cleanupExternalWebhook`: -```typescript -export async function cleanupExternalWebhook(...): Promise { - // ... existing providers ... - } else if (webhook.provider === '{service}') { - await delete{Service}Webhook(webhook, requestId) - } -} -``` +- `getProviderConfig(webhook)` — safely extract `providerConfig` as `Record` +- `getNotificationUrl(webhook)` — build the full callback URL: `{baseUrl}/api/webhooks/trigger/{path}` +- `getCredentialOwner(credentialId, requestId)` — resolve OAuth credential to `{ userId, accountId }` (for OAuth-based providers like Airtable, Attio) ### Key Points for Automatic Registration - **API Key visibility**: Always use `password: true` for API key fields -- **Error handling**: Roll back the database webhook if external creation fails -- **External ID storage**: Save the external webhook ID in `providerConfig.externalId` -- **Graceful cleanup**: Don't fail webhook deletion if cleanup fails (use non-fatal logging) -- **User-friendly errors**: Map HTTP status codes to helpful error messages +- **Error handling**: Throw from `createSubscription` — the orchestration layer catches it, rolls back the DB webhook, and returns a 500 +- **External ID storage**: Return `{ providerConfigUpdates: { externalId } }` — the orchestration layer merges it into `providerConfig` +- **Graceful cleanup**: In `deleteSubscription`, catch errors and log non-fatally (never throw) +- **User-friendly errors**: Map HTTP status codes to helpful error messages in `createSubscription` ## The buildTriggerSubBlocks Helper @@ -552,6 +511,148 @@ All fields automatically have: - `mode: 'trigger'` - Only shown in trigger mode - `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected +## Webhook Provider Handler (Optional) + +If the service requires **custom webhook auth** (HMAC signatures, token validation), **event matching** (filtering by trigger type), **idempotency dedup**, **custom input formatting**, or **subscription lifecycle** — all of this lives in a single provider handler file. + +### Directory + +``` +apps/sim/lib/webhooks/providers/ +├── types.ts # WebhookProviderHandler interface (16 optional methods) +├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes) +├── subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl, getCredentialOwner) +├── registry.ts # Handler map + default handler +├── index.ts # Barrel export +└── {service}.ts # Your provider handler (ALL provider-specific logic here) +``` + +### When to Create a Handler + +| Behavior | Method to implement | Example providers | +|---|---|---| +| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform | +| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms | +| Event type filtering | `matchEvent` | GitHub, Jira, Confluence, Attio, HubSpot | +| Event skip by type list | `shouldSkipEvent` via `skipByEventTypes` | Stripe, Grain | +| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira | +| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Microsoft Teams | +| Custom error format | `formatErrorResponse` | Microsoft Teams | +| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby, Gmail, Outlook | +| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable, Typeform | +| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable, Typeform | +| Polling setup | `configurePolling` | Gmail, Outlook, RSS, IMAP | +| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Microsoft Teams | + +If none of these apply, you do NOT need a handler file. The default handler provides bearer token auth for providers that set `providerConfig.token`. + +### Simple Example: HMAC Auth Only + +Signature validators are defined as private functions **inside the handler file** (not in a shared utils file): + +```typescript +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:{Service}') + +function validate{Service}Signature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) return false + if (!signature.startsWith('sha256=')) return false + const provided = signature.substring(7) + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, provided) + } catch (error) { + logger.error('Error validating {Service} signature:', error) + return false + } +} + +export const {service}Handler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-{Service}-Signature', + validateFn: validate{Service}Signature, + providerLabel: '{Service}', + }), +} +``` + +### Example: Auth + Event Matching + Idempotency + +```typescript +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { EventMatchContext, WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:{Service}') + +function validate{Service}Signature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) return false + const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + return safeCompare(computed, signature) + } catch (error) { + logger.error('Error validating {Service} signature:', error) + return false + } +} + +export const {service}Handler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-{Service}-Signature', + validateFn: validate{Service}Signature, + providerLabel: '{Service}', + }), + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== '{service}_webhook') { + const { is{Service}EventMatch } = await import('@/triggers/{service}/utils') + if (!is{Service}EventMatch(triggerId, obj)) { + logger.debug( + `[${requestId}] {Service} event mismatch for trigger ${triggerId}. Skipping.`, + { webhookId: webhook.id, workflowId: workflow.id, triggerId } + ) + return false + } + } + + return true + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj.id && obj.type) { + return `${obj.type}:${obj.id}` + } + return null + }, +} +``` + +### Registering the Handler + +In `apps/sim/lib/webhooks/providers/registry.ts`: + +```typescript +import { {service}Handler } from '@/lib/webhooks/providers/{service}' + +const PROVIDER_HANDLERS: Record = { + // ... existing providers (alphabetical) ... + {service}: {service}Handler, +} +``` + ## Trigger Outputs & Webhook Input Formatting ### Important: Two Sources of Truth @@ -559,35 +660,48 @@ All fields automatically have: There are two related but separate concerns: 1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown. -2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`. +2. **`formatInput` on the handler** - Implementation that transforms raw webhook payload into actual data. Defined in `apps/sim/lib/webhooks/providers/{service}.ts`. -**These MUST be aligned.** The fields returned by `formatWebhookInput` should match what's defined in trigger `outputs`. If they differ: +**These MUST be aligned.** The fields returned by `formatInput` should match what's defined in trigger `outputs`. If they differ: - Tag dropdown shows fields that don't exist (broken variable resolution) - Or actual data has fields not shown in dropdown (users can't discover them) -### When to Add a formatWebhookInput Handler +### When to Add `formatInput` -- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need a handler. The generic fallback returns `body` directly. -- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add a handler. +- **Simple providers**: If the raw webhook payload structure already matches your outputs, you don't need it. The fallback passes through the raw body directly. +- **Complex providers**: If you need to transform, flatten, extract nested data, compute fields, or handle conditional logic, add `formatInput` to your handler. -### Adding a Handler +### Adding `formatInput` to Your Handler -In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block: +In `apps/sim/lib/webhooks/providers/{service}.ts`: ```typescript -if (foundWebhook.provider === '{service}') { - // Transform raw webhook body to match trigger outputs - return { - eventType: body.type, - resourceId: body.data?.id || '', - timestamp: body.created_at, - resource: body.data, - } +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const {service}Handler: WebhookProviderHandler = { + // ... other methods ... + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + eventType: b.type, + resourceId: (b.data as Record)?.id || '', + timestamp: b.created_at, + resource: b.data, + }, + } + }, } ``` **Key rules:** -- Return fields that match your trigger `outputs` definition exactly +- Return `{ input: { ... } }` where the inner object matches your trigger `outputs` definition exactly +- Return `{ input: ..., skip: { message: '...' } }` to skip execution for this event - No wrapper objects like `webhook: { data: ... }` or `{service}: { ... }` - No duplication (don't spread body AND add individual fields) - Use `null` for missing optional data, not empty objects with empty strings @@ -688,21 +802,25 @@ export const {service}WebhookTrigger: TriggerConfig = { - [ ] Block has all trigger IDs in `triggers.available` - [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks` +### Webhook Provider Handler (`providers/{service}.ts`) +- [ ] Created handler file in `apps/sim/lib/webhooks/providers/{service}.ts` +- [ ] Registered handler in `apps/sim/lib/webhooks/providers/registry.ts` (alphabetical) +- [ ] Signature validator defined as private function inside handler file (not in a shared file) +- [ ] Used `createHmacVerifier` from `providers/utils` for HMAC-based auth +- [ ] Used `verifyTokenAuth` from `providers/utils` for token-based auth +- [ ] Event matching uses dynamic `await import()` for trigger utils +- [ ] Added `formatInput` if webhook payload needs transformation (returns `{ input: ... }`) + ### Automatic Webhook Registration (if supported) - [ ] Added API key field to `build{Service}ExtraFields` with `password: true` - [ ] Updated setup instructions for automatic webhook creation -- [ ] Added provider-specific logic to `apps/sim/app/api/webhooks/route.ts` -- [ ] Added `create{Service}WebhookSubscription` helper function -- [ ] Added `delete{Service}Webhook` function to `provider-subscriptions.ts` -- [ ] Added provider to `cleanupExternalWebhook` function - -### Webhook Input Formatting -- [ ] Added handler in `apps/sim/lib/webhooks/utils.server.ts` (if custom formatting needed) -- [ ] Handler returns fields matching trigger `outputs` exactly -- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify alignment +- [ ] Added `createSubscription` method to handler (uses `getNotificationUrl`, `getProviderConfig` from `subscription-utils`) +- [ ] Added `deleteSubscription` method to handler (catches errors, logs non-fatally) +- [ ] NO changes needed to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts` ### Testing - [ ] Run `bun run type-check` to verify no TypeScript errors +- [ ] Run `bunx scripts/check-trigger-alignment.ts {service}` to verify output alignment - [ ] Restart dev server to pick up new triggers - [ ] Test trigger UI shows correctly in the block - [ ] Test automatic webhook creation works (if applicable) diff --git a/apps/sim/app/api/webhooks/route.ts b/apps/sim/app/api/webhooks/route.ts index ea17b75cefe..c6ef9e992e1 100644 --- a/apps/sim/app/api/webhooks/route.ts +++ b/apps/sim/app/api/webhooks/route.ts @@ -16,13 +16,9 @@ import { createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' +import { getProviderHandler } from '@/lib/webhooks/providers' import { mergeNonUserFields } from '@/lib/webhooks/utils' -import { - configureGmailPolling, - configureOutlookPolling, - configureRssPolling, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { extractCredentialSetId, isCredentialSetValue } from '@/executor/constants' @@ -348,7 +344,6 @@ export async function POST(request: NextRequest) { workflowRecord.workspaceId || undefined ) - // --- Credential Set Handling --- // For credential sets, we fan out to create one webhook per credential at save time. // This applies to all OAuth-based triggers, not just polling ones. // Check for credentialSetId directly (frontend may already extract it) or credential set value in credential fields @@ -402,16 +397,13 @@ export async function POST(request: NextRequest) { ) } - const needsConfiguration = provider === 'gmail' || provider === 'outlook' + const providerHandler = getProviderHandler(provider) - if (needsConfiguration) { - const configureFunc = - provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + if (providerHandler.configurePolling) { const configureErrors: string[] = [] for (const wh of syncResult.webhooks) { if (wh.isNew) { - // Fetch the webhook data for configuration const webhookRows = await db .select() .from(webhook) @@ -419,7 +411,10 @@ export async function POST(request: NextRequest) { .limit(1) if (webhookRows.length > 0) { - const success = await configureFunc(webhookRows[0], requestId) + const success = await providerHandler.configurePolling({ + webhook: webhookRows[0], + requestId, + }) if (!success) { configureErrors.push( `Failed to configure webhook for credential ${wh.credentialId}` @@ -436,7 +431,6 @@ export async function POST(request: NextRequest) { configureErrors.length > 0 && configureErrors.length === syncResult.webhooks.length ) { - // All configurations failed - roll back logger.error(`[${requestId}] All webhook configurations failed, rolling back`) for (const wh of syncResult.webhooks) { await db.delete(webhook).where(eq(webhook.id, wh.id)) @@ -488,8 +482,6 @@ export async function POST(request: NextRequest) { } } } - // --- End Credential Set Handling --- - let externalSubscriptionCreated = false const createTempWebhookData = (providerConfigOverride = resolvedProviderConfig) => ({ id: targetWebhookId || generateShortId(), @@ -629,115 +621,49 @@ export async function POST(request: NextRequest) { } } - // --- Gmail/Outlook webhook setup (these don't require external subscriptions, configure after DB save) --- - if (savedWebhook && provider === 'gmail') { - logger.info(`[${requestId}] Gmail provider detected. Setting up Gmail webhook configuration.`) - try { - const success = await configureGmailPolling(savedWebhook, requestId) - - if (!success) { - logger.error(`[${requestId}] Failed to configure Gmail polling, rolling back webhook`) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Gmail polling', - details: 'Please check your Gmail account permissions and try again', - }, - { status: 500 } - ) - } - - logger.info(`[${requestId}] Successfully configured Gmail polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up Gmail webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Gmail webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } + if (savedWebhook) { + const pollingHandler = getProviderHandler(provider) + if (pollingHandler.configurePolling) { + logger.info( + `[${requestId}] ${provider} provider detected. Setting up polling configuration.` ) - } - } - // --- End Gmail specific logic --- + try { + const success = await pollingHandler.configurePolling({ + webhook: savedWebhook, + requestId, + }) - // --- Outlook webhook setup --- - if (savedWebhook && provider === 'outlook') { - logger.info( - `[${requestId}] Outlook provider detected. Setting up Outlook webhook configuration.` - ) - try { - const success = await configureOutlookPolling(savedWebhook, requestId) + if (!success) { + logger.error( + `[${requestId}] Failed to configure ${provider} polling, rolling back webhook` + ) + await revertSavedWebhook(savedWebhook, existingWebhook, requestId) + return NextResponse.json( + { + error: `Failed to configure ${provider} polling`, + details: 'Please check your account permissions and try again', + }, + { status: 500 } + ) + } - if (!success) { - logger.error(`[${requestId}] Failed to configure Outlook polling, rolling back webhook`) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Outlook polling', - details: 'Please check your Outlook account permissions and try again', - }, - { status: 500 } + logger.info(`[${requestId}] Successfully configured ${provider} polling`) + } catch (err) { + logger.error( + `[${requestId}] Error setting up ${provider} webhook configuration, rolling back webhook`, + err ) - } - - logger.info(`[${requestId}] Successfully configured Outlook polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up Outlook webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure Outlook webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) - } - } - // --- End Outlook specific logic --- - - // --- RSS webhook setup --- - if (savedWebhook && provider === 'rss') { - logger.info(`[${requestId}] RSS provider detected. Setting up RSS webhook configuration.`) - try { - const success = await configureRssPolling(savedWebhook, requestId) - - if (!success) { - logger.error(`[${requestId}] Failed to configure RSS polling, rolling back webhook`) await revertSavedWebhook(savedWebhook, existingWebhook, requestId) return NextResponse.json( { - error: 'Failed to configure RSS polling', - details: 'Please try again', + error: `Failed to configure ${provider} webhook`, + details: err instanceof Error ? err.message : 'Unknown error', }, { status: 500 } ) } - - logger.info(`[${requestId}] Successfully configured RSS polling`) - } catch (err) { - logger.error( - `[${requestId}] Error setting up RSS webhook configuration, rolling back webhook`, - err - ) - await revertSavedWebhook(savedWebhook, existingWebhook, requestId) - return NextResponse.json( - { - error: 'Failed to configure RSS webhook', - details: err instanceof Error ? err.message : 'Unknown error', - }, - { status: 500 } - ) } } - // --- End RSS specific logic --- if (!targetWebhookId && savedWebhook) { try { diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts index 7ee5d198f3f..88073b11cba 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.test.ts @@ -97,7 +97,6 @@ const { handleSlackChallengeMock, processWhatsAppDeduplicationMock, processGenericDeduplicationMock, - fetchAndProcessAirtablePayloadsMock, processWebhookMock, executeMock, getWorkspaceBilledAccountUserIdMock, @@ -109,7 +108,6 @@ const { handleSlackChallengeMock: vi.fn().mockReturnValue(null), processWhatsAppDeduplicationMock: vi.fn().mockResolvedValue(null), processGenericDeduplicationMock: vi.fn().mockResolvedValue(null), - fetchAndProcessAirtablePayloadsMock: vi.fn().mockResolvedValue(undefined), processWebhookMock: vi.fn().mockResolvedValue(new Response('Webhook processed', { status: 200 })), executeMock: vi.fn().mockResolvedValue({ success: true, @@ -156,10 +154,8 @@ vi.mock('@/background/logs-webhook-delivery', () => ({ vi.mock('@/lib/webhooks/utils', () => ({ handleWhatsAppVerification: handleWhatsAppVerificationMock, handleSlackChallenge: handleSlackChallengeMock, - verifyProviderWebhook: vi.fn().mockReturnValue(null), processWhatsAppDeduplication: processWhatsAppDeduplicationMock, processGenericDeduplication: processGenericDeduplicationMock, - fetchAndProcessAirtablePayloads: fetchAndProcessAirtablePayloadsMock, processWebhook: processWebhookMock, })) diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 2c283b72fdb..a04c749af50 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -87,7 +87,7 @@ async function handleWebhookPost( if (webhooksForPath.length === 0) { const verificationResponse = await handlePreLookupWebhookVerification( request.method, - body, + body as Record | undefined, requestId, path ) diff --git a/apps/sim/background/webhook-execution.ts b/apps/sim/background/webhook-execution.ts index f9e01c6300c..7926ea0e382 100644 --- a/apps/sim/background/webhook-execution.ts +++ b/apps/sim/background/webhook-execution.ts @@ -7,12 +7,11 @@ import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' import { IdempotencyService, webhookIdempotency } from '@/lib/core/idempotency' import { generateId } from '@/lib/core/utils/uuid' -import { processExecutionFiles } from '@/lib/execution/files' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { WebhookAttachmentProcessor } from '@/lib/webhooks/attachment-processor' -import { fetchAndProcessAirtablePayloads, formatWebhookInput } from '@/lib/webhooks/utils.server' +import { getProviderHandler } from '@/lib/webhooks/providers' import { executeWorkflowCore, wasExecutionFinalizedByCore, @@ -23,6 +22,7 @@ import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' import { getBlock } from '@/blocks' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata } from '@/executor/execution/types' +import type { ExecutionResult } from '@/executor/types' import { hasExecutionResult } from '@/executor/utils/errors' import { safeAssign } from '@/tools/safe-assign' import { getTrigger, isTriggerValid } from '@/triggers' @@ -48,12 +48,12 @@ export function buildWebhookCorrelation( } /** - * Process trigger outputs based on their schema definitions - * Finds outputs marked as 'file' or 'file[]' and uploads them to execution storage + * Process trigger outputs based on their schema definitions. + * Finds outputs marked as 'file' or 'file[]' and uploads them to execution storage. */ async function processTriggerFileOutputs( - input: any, - triggerOutputs: Record, + input: unknown, + triggerOutputs: Record, context: { workspaceId: string workflowId: string @@ -62,29 +62,31 @@ async function processTriggerFileOutputs( userId?: string }, path = '' -): Promise { +): Promise { if (!input || typeof input !== 'object') { return input } - const processed: any = Array.isArray(input) ? [] : {} + const processed = (Array.isArray(input) ? [] : {}) as Record for (const [key, value] of Object.entries(input)) { const currentPath = path ? `${path}.${key}` : key - const outputDef = triggerOutputs[key] - const val: any = value + const outputDef = triggerOutputs[key] as Record | undefined + const val = value as Record - // If this field is marked as file or file[], process it if (outputDef?.type === 'file[]' && Array.isArray(val)) { try { - processed[key] = await WebhookAttachmentProcessor.processAttachments(val as any, context) + processed[key] = await WebhookAttachmentProcessor.processAttachments( + val as unknown as Parameters[0], + context + ) } catch (error) { processed[key] = [] } } else if (outputDef?.type === 'file' && val) { try { const [processedFile] = await WebhookAttachmentProcessor.processAttachments( - [val as any], + [val] as unknown as Parameters[0], context ) processed[key] = processedFile @@ -98,18 +100,20 @@ async function processTriggerFileOutputs( (outputDef.type === 'object' || outputDef.type === 'json') && outputDef.properties ) { - // Explicit object schema with properties - recurse into properties processed[key] = await processTriggerFileOutputs( val, - outputDef.properties, + outputDef.properties as Record, context, currentPath ) } else if (outputDef && typeof outputDef === 'object' && !outputDef.type) { - // Nested object in schema (flat pattern) - recurse with the nested schema - processed[key] = await processTriggerFileOutputs(val, outputDef, context, currentPath) + processed[key] = await processTriggerFileOutputs( + val, + outputDef as Record, + context, + currentPath + ) } else { - // Not a file output - keep as is processed[key] = val } } @@ -125,7 +129,7 @@ export type WebhookExecutionPayload = { requestId?: string correlation?: AsyncExecutionCorrelation provider: string - body: any + body: unknown headers: Record path: string blockId?: string @@ -164,9 +168,6 @@ export async function executeWebhookJob(payload: WebhookExecutionPayload) { ) } -/** - * Resolve the account userId for a credential - */ async function resolveCredentialAccountUserId(credentialId: string): Promise { const resolved = await resolveOAuthAccountId(credentialId) if (!resolved) { @@ -180,6 +181,62 @@ async function resolveCredentialAccountUserId(credentialId: string): Promise + requestId: string + executionId: string + workflowId: string + } +) { + if ( + executionResult.status === 'cancelled' && + ctx.timeoutController.isTimedOut() && + ctx.timeoutController.timeoutMs + ) { + const timeoutErrorMessage = getTimeoutErrorMessage(null, ctx.timeoutController.timeoutMs) + logger.info(`[${ctx.requestId}] Webhook execution timed out`, { + timeoutMs: ctx.timeoutController.timeoutMs, + }) + await ctx.loggingSession.markAsFailed(timeoutErrorMessage) + } else if (executionResult.status === 'paused') { + if (!executionResult.snapshotSeed) { + logger.error(`[${ctx.requestId}] Missing snapshot seed for paused execution`, { + executionId: ctx.executionId, + }) + await ctx.loggingSession.markAsFailed('Missing snapshot seed for paused execution') + } else { + try { + await PauseResumeManager.persistPauseResult({ + workflowId: ctx.workflowId, + executionId: ctx.executionId, + pausePoints: executionResult.pausePoints || [], + snapshotSeed: executionResult.snapshotSeed, + executorUserId: executionResult.metadata?.userId, + }) + } catch (pauseError) { + logger.error(`[${ctx.requestId}] Failed to persist pause result`, { + executionId: ctx.executionId, + error: pauseError instanceof Error ? pauseError.message : String(pauseError), + }) + await ctx.loggingSession.markAsFailed( + `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` + ) + } + } + } else { + await PauseResumeManager.processQueuedResumes(ctx.executionId) + } + + await ctx.loggingSession.waitForPostExecution() +} + async function executeWebhookJobInternal( payload: WebhookExecutionPayload, correlation: AsyncExecutionCorrelation @@ -192,7 +249,6 @@ async function executeWebhookJobInternal( requestId ) - // Resolve workflow record, billing actor, subscription, and timeout const preprocessResult = await preprocessExecution({ workflowId: payload.workflowId, userId: payload.userId, @@ -221,14 +277,13 @@ async function executeWebhookJobInternal( throw new Error(`Workflow ${payload.workflowId} has no associated workspace`) } - const workflowVariables = (workflowRecord.variables as Record) || {} + const workflowVariables = (workflowRecord.variables as Record) || {} const asyncTimeout = executionTimeout?.async ?? 120_000 const timeoutController = createTimeoutAbortController(asyncTimeout) let deploymentVersionId: string | undefined try { - // Parallelize workflow state, webhook record, and credential resolution const [workflowData, webhookRows, resolvedCredentialUserId] = await Promise.all([ loadDeployedWorkflowState(payload.workflowId, workspaceId), db.select().from(webhook).where(eq(webhook.id, payload.webhookId)).limit(1), @@ -255,184 +310,38 @@ async function executeWebhookJobInternal( ? (workflowData.deploymentVersionId as string) : undefined - // Handle special Airtable case - if (payload.provider === 'airtable') { - logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`) - - const webhookRecord = webhookRows[0] - if (!webhookRecord) { - throw new Error(`Webhook record not found: ${payload.webhookId}`) - } - - const webhookData = { - id: payload.webhookId, - provider: payload.provider, - providerConfig: webhookRecord.providerConfig, - } - - const mockWorkflow = { - id: payload.workflowId, - userId: payload.userId, - } - - const airtableInput = await fetchAndProcessAirtablePayloads( - webhookData, - mockWorkflow, - requestId - ) - - if (airtableInput) { - logger.info(`[${requestId}] Executing workflow with Airtable changes`) - - const metadata: ExecutionMetadata = { - requestId, - executionId, - workflowId: payload.workflowId, - workspaceId, - userId: payload.userId, - sessionUserId: undefined, - workflowUserId: workflowRecord.userId, - triggerType: payload.provider || 'webhook', - triggerBlockId: payload.blockId, - useDraftState: false, - startTime: new Date().toISOString(), - isClientSession: false, - credentialAccountUserId, - correlation, - workflowStateOverride: { - blocks, - edges, - loops: loops || {}, - parallels: parallels || {}, - deploymentVersionId, - }, - } - - const snapshot = new ExecutionSnapshot( - metadata, - workflowRecord, - airtableInput, - workflowVariables, - [] - ) - - const executionResult = await executeWorkflowCore({ - snapshot, - callbacks: {}, - loggingSession, - includeFileBase64: true, - base64MaxBytes: undefined, - abortSignal: timeoutController.signal, - }) - - if ( - executionResult.status === 'cancelled' && - timeoutController.isTimedOut() && - timeoutController.timeoutMs - ) { - const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Airtable webhook execution timed out`, { - timeoutMs: timeoutController.timeoutMs, - }) - await loggingSession.markAsFailed(timeoutErrorMessage) - } else if (executionResult.status === 'paused') { - if (!executionResult.snapshotSeed) { - logger.error(`[${requestId}] Missing snapshot seed for paused execution`, { - executionId, - }) - await loggingSession.markAsFailed('Missing snapshot seed for paused execution') - } else { - try { - await PauseResumeManager.persistPauseResult({ - workflowId: payload.workflowId, - executionId, - pausePoints: executionResult.pausePoints || [], - snapshotSeed: executionResult.snapshotSeed, - executorUserId: executionResult.metadata?.userId, - }) - } catch (pauseError) { - logger.error(`[${requestId}] Failed to persist pause result`, { - executionId, - error: pauseError instanceof Error ? pauseError.message : String(pauseError), - }) - await loggingSession.markAsFailed( - `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` - ) - } - } - } else { - await PauseResumeManager.processQueuedResumes(executionId) - } - - await loggingSession.waitForPostExecution() - - logger.info(`[${requestId}] Airtable webhook execution completed`, { - success: executionResult.success, - workflowId: payload.workflowId, - }) + const handler = getProviderHandler(payload.provider) - return { - success: executionResult.success, - workflowId: payload.workflowId, - executionId, - output: executionResult.output, - executedAt: new Date().toISOString(), - provider: payload.provider, - } - } - // No changes to process - logger.info(`[${requestId}] No Airtable changes to process`) + let input: Record | null = null + let skipMessage: string | undefined - await loggingSession.safeStart({ - userId: payload.userId, - workspaceId, - variables: {}, - triggerData: { - isTest: false, - correlation, - }, - deploymentVersionId, - }) + const webhookRecord = webhookRows[0] + if (!webhookRecord) { + throw new Error(`Webhook record not found: ${payload.webhookId}`) + } - await loggingSession.safeComplete({ - endedAt: new Date().toISOString(), - totalDurationMs: 0, - finalOutput: { message: 'No Airtable changes to process' }, - traceSpans: [], + if (handler.formatInput) { + const result = await handler.formatInput({ + webhook: webhookRecord, + workflow: { id: payload.workflowId, userId: payload.userId }, + body: payload.body, + headers: payload.headers, + requestId, }) - - return { - success: true, - workflowId: payload.workflowId, - executionId, - output: { message: 'No Airtable changes to process' }, - executedAt: new Date().toISOString(), - } + input = result.input as Record | null + skipMessage = result.skip?.message + } else { + input = payload.body as Record | null } - // Format input for standard webhooks - const actualWebhook = - webhookRows.length > 0 - ? webhookRows[0] - : { - provider: payload.provider, - blockId: payload.blockId, - providerConfig: {}, - } - - const mockWorkflow = { - id: payload.workflowId, - userId: payload.userId, + if (!input && handler.handleEmptyInput) { + const skipResult = handler.handleEmptyInput(requestId) + if (skipResult) { + skipMessage = skipResult.message + } } - const mockRequest = { - headers: new Map(Object.entries(payload.headers)), - } as any - - const input = await formatWebhookInput(actualWebhook, mockWorkflow, payload.body, mockRequest) - - if (!input && payload.provider === 'whatsapp') { - logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) + if (skipMessage) { await loggingSession.safeStart({ userId: payload.userId, workspaceId, @@ -447,19 +356,19 @@ async function executeWebhookJobInternal( await loggingSession.safeComplete({ endedAt: new Date().toISOString(), totalDurationMs: 0, - finalOutput: { message: 'No messages in WhatsApp payload' }, + finalOutput: { message: skipMessage }, traceSpans: [], }) + return { success: true, workflowId: payload.workflowId, executionId, - output: { message: 'No messages in WhatsApp payload' }, + output: { message: skipMessage }, executedAt: new Date().toISOString(), } } - // Process trigger file outputs based on schema if (input && payload.blockId && blocks[payload.blockId]) { try { const triggerBlock = blocks[payload.blockId] @@ -502,49 +411,20 @@ async function executeWebhookJobInternal( } } - // Process generic webhook files based on inputFormat - if (input && payload.provider === 'generic' && payload.blockId && blocks[payload.blockId]) { + if (input && handler.processInputFiles && payload.blockId && blocks[payload.blockId]) { try { - const triggerBlock = blocks[payload.blockId] - - if (triggerBlock?.subBlocks?.inputFormat?.value) { - const inputFormat = triggerBlock.subBlocks.inputFormat.value as unknown as Array<{ - name: string - type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' - }> - - const fileFields = inputFormat.filter((field) => field.type === 'file[]') - - if (fileFields.length > 0 && typeof input === 'object' && input !== null) { - const executionContext = { - workspaceId, - workflowId: payload.workflowId, - executionId, - } - - for (const fileField of fileFields) { - const fieldValue = input[fileField.name] - - if (fieldValue && typeof fieldValue === 'object') { - const uploadedFiles = await processExecutionFiles( - fieldValue, - executionContext, - requestId, - payload.userId - ) - - if (uploadedFiles.length > 0) { - input[fileField.name] = uploadedFiles - logger.info( - `[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}` - ) - } - } - } - } - } + await handler.processInputFiles({ + input, + blocks, + blockId: payload.blockId, + workspaceId, + workflowId: payload.workflowId, + executionId, + requestId, + userId: payload.userId, + }) } catch (error) { - logger.error(`[${requestId}] Error processing generic webhook files:`, error) + logger.error(`[${requestId}] Error processing provider-specific files:`, error) } } @@ -589,49 +469,17 @@ async function executeWebhookJobInternal( callbacks: {}, loggingSession, includeFileBase64: true, + base64MaxBytes: undefined, abortSignal: timeoutController.signal, }) - if ( - executionResult.status === 'cancelled' && - timeoutController.isTimedOut() && - timeoutController.timeoutMs - ) { - const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) - logger.info(`[${requestId}] Webhook execution timed out`, { - timeoutMs: timeoutController.timeoutMs, - }) - await loggingSession.markAsFailed(timeoutErrorMessage) - } else if (executionResult.status === 'paused') { - if (!executionResult.snapshotSeed) { - logger.error(`[${requestId}] Missing snapshot seed for paused execution`, { - executionId, - }) - await loggingSession.markAsFailed('Missing snapshot seed for paused execution') - } else { - try { - await PauseResumeManager.persistPauseResult({ - workflowId: payload.workflowId, - executionId, - pausePoints: executionResult.pausePoints || [], - snapshotSeed: executionResult.snapshotSeed, - executorUserId: executionResult.metadata?.userId, - }) - } catch (pauseError) { - logger.error(`[${requestId}] Failed to persist pause result`, { - executionId, - error: pauseError instanceof Error ? pauseError.message : String(pauseError), - }) - await loggingSession.markAsFailed( - `Failed to persist pause state: ${pauseError instanceof Error ? pauseError.message : String(pauseError)}` - ) - } - } - } else { - await PauseResumeManager.processQueuedResumes(executionId) - } - - await loggingSession.waitForPostExecution() + await handleExecutionResult(executionResult, { + loggingSession, + timeoutController, + requestId, + executionId, + workflowId: payload.workflowId, + }) logger.info(`[${requestId}] Webhook execution completed`, { success: executionResult.success, diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts index d9adf4f504f..27d0746e2a9 100644 --- a/apps/sim/lib/core/idempotency/service.ts +++ b/apps/sim/lib/core/idempotency/service.ts @@ -6,7 +6,7 @@ import { getRedisClient } from '@/lib/core/config/redis' import { getMaxExecutionTimeout } from '@/lib/core/execution-limits' import { getStorageMethod, type StorageMethod } from '@/lib/core/storage' import { generateId } from '@/lib/core/utils/uuid' -import { extractProviderIdentifierFromBody } from '@/lib/webhooks/provider-utils' +import { extractProviderIdentifierFromBody } from '@/lib/webhooks/providers' const logger = createLogger('IdempotencyService') diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index 1dd2f8b8f99..43188e88f2b 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -11,11 +11,8 @@ import { createExternalWebhookSubscription, shouldRecreateExternalWebhookSubscription, } from '@/lib/webhooks/provider-subscriptions' -import { - configureGmailPolling, - configureOutlookPolling, - syncWebhooksForCredentialSet, -} from '@/lib/webhooks/utils.server' +import { getProviderHandler } from '@/lib/webhooks/providers' +import { syncWebhooksForCredentialSet } from '@/lib/webhooks/utils.server' import { getBlock } from '@/blocks' import type { SubBlockConfig } from '@/blocks/types' import type { BlockState } from '@/stores/workflows/workflow/types' @@ -233,29 +230,20 @@ function buildProviderConfig( async function configurePollingIfNeeded( provider: string, - savedWebhook: any, + savedWebhook: Record, requestId: string ): Promise { - if (provider === 'gmail') { - const success = await configureGmailPolling(savedWebhook, requestId) - if (!success) { - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return { - message: 'Failed to configure Gmail polling. Please check your Gmail account permissions.', - status: 500, - } - } + const handler = getProviderHandler(provider) + if (!handler.configurePolling) { + return null } - if (provider === 'outlook') { - const success = await configureOutlookPolling(savedWebhook, requestId) - if (!success) { - await db.delete(webhook).where(eq(webhook.id, savedWebhook.id)) - return { - message: - 'Failed to configure Outlook polling. Please check your Outlook account permissions.', - status: 500, - } + const success = await handler.configurePolling({ webhook: savedWebhook, requestId }) + if (!success) { + await db.delete(webhook).where(eq(webhook.id, savedWebhook.id as string)) + return { + message: `Failed to configure ${provider} polling. Please check your account permissions.`, + status: 500, } } @@ -297,7 +285,7 @@ async function syncCredentialSetWebhooks(params: { basePath: triggerPath, credentialSetId, oauthProviderId, - providerConfig: baseConfig as Record, + providerConfig: baseConfig as Record, requestId, deploymentVersionId, }) @@ -322,13 +310,13 @@ async function syncCredentialSetWebhooks(params: { } } - if (provider === 'gmail' || provider === 'outlook') { - const configureFunc = provider === 'gmail' ? configureGmailPolling : configureOutlookPolling + const handler = getProviderHandler(provider) + if (handler.configurePolling) { for (const wh of syncResult.webhooks) { if (wh.isNew) { const rows = await db.select().from(webhook).where(eq(webhook.id, wh.id)).limit(1) if (rows.length > 0) { - const success = await configureFunc(rows[0], requestId) + const success = await handler.configurePolling({ webhook: rows[0], requestId }) if (!success) { await db.delete(webhook).where(eq(webhook.id, wh.id)) return { @@ -459,6 +447,18 @@ export async function saveTriggerWebhooksForDeploy({ } } + if (providerConfig.requireAuth && !providerConfig.token) { + await restorePreviousSubscriptions() + return { + success: false, + error: { + message: + 'Authentication is enabled but no token is configured. Please set an authentication token or disable authentication.', + status: 400, + }, + } + } + webhookConfigs.set(block.id, { provider, providerConfig, triggerPath, triggerDef }) if (providerConfig.credentialSetId) { @@ -558,13 +558,13 @@ export async function saveTriggerWebhooksForDeploy({ await restorePreviousSubscriptions() return { success: false, error: syncResult.error, warnings: collectedWarnings } } - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Failed to create webhook for ${block.id}`, error) await restorePreviousSubscriptions() return { success: false, error: { - message: error?.message || 'Failed to save trigger configuration', + message: (error as Error)?.message || 'Failed to save trigger configuration', status: 500, }, warnings: collectedWarnings, @@ -621,7 +621,7 @@ export async function saveTriggerWebhooksForDeploy({ updatedProviderConfig: result.updatedProviderConfig as Record, externalSubscriptionCreated: result.externalSubscriptionCreated, }) - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${requestId}] Failed to create external subscription for ${block.id}`, error) await pendingVerificationTracker.clearAll() for (const sub of createdSubscriptions) { @@ -649,7 +649,7 @@ export async function saveTriggerWebhooksForDeploy({ return { success: false, error: { - message: error?.message || 'Failed to create external subscription', + message: (error as Error)?.message || 'Failed to create external subscription', status: 500, }, } @@ -722,7 +722,7 @@ export async function saveTriggerWebhooksForDeploy({ return { success: false, error: pollingError } } } - } catch (error: any) { + } catch (error: unknown) { await pendingVerificationTracker.clearAll() logger.error(`[${requestId}] Failed to insert webhook records`, error) for (const sub of createdSubscriptions) { @@ -750,7 +750,7 @@ export async function saveTriggerWebhooksForDeploy({ return { success: false, error: { - message: error?.message || 'Failed to save webhook records', + message: (error as Error)?.message || 'Failed to save webhook records', status: 500, }, } diff --git a/apps/sim/lib/webhooks/processor.test.ts b/apps/sim/lib/webhooks/processor.test.ts index e3cb8dcde41..3f543780f71 100644 --- a/apps/sim/lib/webhooks/processor.test.ts +++ b/apps/sim/lib/webhooks/processor.test.ts @@ -106,17 +106,10 @@ vi.mock('@/lib/webhooks/utils', () => ({ vi.mock('@/lib/webhooks/utils.server', () => ({ handleSlackChallenge: vi.fn().mockReturnValue(null), handleWhatsAppVerification: vi.fn().mockResolvedValue(null), - validateAttioSignature: vi.fn().mockReturnValue(true), - validateCalcomSignature: vi.fn().mockReturnValue(true), - validateCirclebackSignature: vi.fn().mockReturnValue(true), - validateFirefliesSignature: vi.fn().mockReturnValue(true), - validateGitHubSignature: vi.fn().mockReturnValue(true), - validateJiraSignature: vi.fn().mockReturnValue(true), - validateLinearSignature: vi.fn().mockReturnValue(true), - validateMicrosoftTeamsSignature: vi.fn().mockReturnValue(true), - validateTwilioSignature: vi.fn().mockResolvedValue(true), - validateTypeformSignature: vi.fn().mockReturnValue(true), - verifyProviderWebhook: vi.fn().mockReturnValue(null), +})) + +vi.mock('@/lib/webhooks/providers', () => ({ + getProviderHandler: vi.fn().mockReturnValue({}), })) vi.mock('@/background/webhook-execution', () => ({ diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts index 34de5f54ba6..38f6cc81bbc 100644 --- a/apps/sim/lib/webhooks/processor.ts +++ b/apps/sim/lib/webhooks/processor.ts @@ -8,7 +8,6 @@ import { getInlineJobQueue, getJobQueue, shouldExecuteInline } from '@/lib/core/ import type { AsyncExecutionCorrelation } from '@/lib/core/async-jobs/types' import { createBullMQJobData, isBullMQEnabled } from '@/lib/core/bullmq' import { isProd } from '@/lib/core/config/feature-flags' -import { safeCompare } from '@/lib/core/security/encryption' import { generateId } from '@/lib/core/utils/uuid' import { enqueueWorkspaceDispatch } from '@/lib/core/workspace-dispatch' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' @@ -18,30 +17,10 @@ import { matchesPendingWebhookVerificationProbe, requiresPendingWebhookVerification, } from '@/lib/webhooks/pending-verification' -import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' -import { - handleSlackChallenge, - handleWhatsAppVerification, - validateAshbySignature, - validateAttioSignature, - validateCalcomSignature, - validateCirclebackSignature, - validateFirefliesSignature, - validateGitHubSignature, - validateJiraSignature, - validateLinearSignature, - validateMicrosoftTeamsSignature, - validateTwilioSignature, - validateTypeformSignature, - verifyProviderWebhook, -} from '@/lib/webhooks/utils.server' +import { getProviderHandler } from '@/lib/webhooks/providers' import { executeWebhookJob } from '@/background/webhook-execution' import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' -import { isConfluencePayloadMatch } from '@/triggers/confluence/utils' import { isPollingWebhookProvider } from '@/triggers/constants' -import { isGitHubEventMatch } from '@/triggers/github/utils' -import { isHubSpotContactEventMatch } from '@/triggers/hubspot/utils' -import { isJiraEventMatch } from '@/triggers/jira/utils' const logger = createLogger('WebhookProcessor') @@ -61,19 +40,6 @@ export interface WebhookPreprocessingResult { correlation?: AsyncExecutionCorrelation } -function getExternalUrl(request: NextRequest): string { - const proto = request.headers.get('x-forwarded-proto') || 'https' - const host = request.headers.get('x-forwarded-host') || request.headers.get('host') - - if (host) { - const url = new URL(request.url) - const reconstructed = `${proto}://${host}${url.pathname}${url.search}` - return reconstructed - } - - return request.url -} - async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ valid: boolean error?: string @@ -106,13 +72,12 @@ async function verifyCredentialSetBilling(credentialSetId: string): Promise<{ export async function parseWebhookBody( request: NextRequest, requestId: string -): Promise<{ body: any; rawBody: string } | NextResponse> { +): Promise<{ body: unknown; rawBody: string } | NextResponse> { let rawBody: string | null = null try { const requestClone = request.clone() rawBody = await requestClone.text() - // Allow empty body - some webhooks send empty payloads if (!rawBody || rawBody.length === 0) { return { body: {}, rawBody: '' } } @@ -123,7 +88,7 @@ export async function parseWebhookBody( return new NextResponse('Failed to read request body', { status: 400 }) } - let body: any + let body: unknown try { const contentType = request.headers.get('content-type') || '' @@ -139,10 +104,6 @@ export async function parseWebhookBody( } else { body = JSON.parse(rawBody) } - - // Allow empty JSON objects - some webhooks send empty payloads - if (Object.keys(body).length === 0) { - } } catch (parseError) { logger.error(`[${requestId}] Failed to parse webhook body`, { error: parseError instanceof Error ? parseError.message : String(parseError), @@ -155,38 +116,24 @@ export async function parseWebhookBody( return { body, rawBody } } +/** Providers that implement challenge/verification handling, checked before webhook lookup. */ +const CHALLENGE_PROVIDERS = ['slack', 'microsoft-teams', 'whatsapp'] as const + export async function handleProviderChallenges( - body: any, + body: unknown, request: NextRequest, requestId: string, path: string ): Promise { - const slackResponse = handleSlackChallenge(body) - if (slackResponse) { - return slackResponse - } - - const url = new URL(request.url) - - // Microsoft Graph subscription validation (can come as GET or POST) - const validationToken = url.searchParams.get('validationToken') - if (validationToken) { - logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) - return new NextResponse(validationToken, { - status: 200, - headers: { 'Content-Type': 'text/plain' }, - }) - } - - const mode = url.searchParams.get('hub.mode') - const token = url.searchParams.get('hub.verify_token') - const challenge = url.searchParams.get('hub.challenge') - - const whatsAppResponse = await handleWhatsAppVerification(requestId, path, mode, token, challenge) - if (whatsAppResponse) { - return whatsAppResponse + for (const provider of CHALLENGE_PROVIDERS) { + const handler = getProviderHandler(provider) + if (handler.handleChallenge) { + const response = await handler.handleChallenge(body, request, requestId, path) + if (response) { + return response + } + } } - return null } @@ -218,108 +165,54 @@ export async function handlePreLookupWebhookVerification( /** * Handle provider-specific reachability tests that occur AFTER webhook lookup. - * - * @param webhook - The webhook record from the database - * @param body - The parsed request body - * @param requestId - Request ID for logging - * @returns NextResponse if this is a verification request, null to continue normal flow + * Delegates to the provider handler registry. */ export function handleProviderReachabilityTest( - webhook: any, - body: any, + webhookRecord: { provider: string }, + body: unknown, requestId: string ): NextResponse | null { - const provider = webhook?.provider - - if (provider === 'grain') { - const isVerificationRequest = !body || Object.keys(body).length === 0 || !body.type - if (isVerificationRequest) { - logger.info( - `[${requestId}] Grain reachability test detected - returning 200 for webhook verification` - ) - return NextResponse.json({ - status: 'ok', - message: 'Webhook endpoint verified', - }) - } - } - - return null + const handler = getProviderHandler(webhookRecord?.provider) + return handler.handleReachabilityTest?.(body, requestId) ?? null } /** * Format error response based on provider requirements. - * Some providers (like Microsoft Teams) require specific response formats. + * Delegates to the provider handler registry. */ export function formatProviderErrorResponse( - webhook: any, + webhookRecord: { provider: string }, error: string, status: number ): NextResponse { - if (webhook.provider === 'microsoft-teams') { - return NextResponse.json({ type: 'message', text: error }, { status }) - } - return NextResponse.json({ error }, { status }) + const handler = getProviderHandler(webhookRecord.provider) + return handler.formatErrorResponse?.(error, status) ?? NextResponse.json({ error }, { status }) } /** * Check if a webhook event should be skipped based on provider-specific filtering. - * Returns true if the event should be skipped, false if it should be processed. + * Delegates to the provider handler registry. */ -export function shouldSkipWebhookEvent(webhook: any, body: any, requestId: string): boolean { - const providerConfig = (webhook.providerConfig as Record) || {} - - if (webhook.provider === 'stripe') { - const eventTypes = providerConfig.eventTypes - if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { - const eventType = body?.type - if (eventType && !eventTypes.includes(eventType)) { - logger.info( - `[${requestId}] Stripe event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping` - ) - return true - } - } - } - - if (webhook.provider === 'grain') { - const eventTypes = providerConfig.eventTypes - if (eventTypes && Array.isArray(eventTypes) && eventTypes.length > 0) { - const eventType = body?.type - if (eventType && !eventTypes.includes(eventType)) { - logger.info( - `[${requestId}] Grain event type '${eventType}' not in allowed list for webhook ${webhook.id}, skipping` - ) - return true - } - } - } - - // Webflow collection filtering - filter by collectionId if configured - if (webhook.provider === 'webflow') { - const configuredCollectionId = providerConfig.collectionId - if (configuredCollectionId) { - const payloadCollectionId = body?.payload?.collectionId || body?.collectionId - if (payloadCollectionId && payloadCollectionId !== configuredCollectionId) { - logger.info( - `[${requestId}] Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id}, skipping` - ) - return true - } - } - } - - return false +export function shouldSkipWebhookEvent( + webhookRecord: { provider: string; providerConfig?: Record }, + body: unknown, + requestId: string +): boolean { + const handler = getProviderHandler(webhookRecord.provider) + const providerConfig = webhookRecord.providerConfig ?? {} + return ( + handler.shouldSkipEvent?.({ webhook: webhookRecord, body, requestId, providerConfig }) ?? false + ) } /** Returns 200 OK for providers that validate URLs before the workflow is deployed */ export function handlePreDeploymentVerification( - webhook: any, + webhookRecord: { provider: string }, requestId: string ): NextResponse | null { - if (requiresPendingWebhookVerification(webhook.provider)) { + if (requiresPendingWebhookVerification(webhookRecord.provider)) { logger.info( - `[${requestId}] ${webhook.provider} webhook - block not in deployment, returning 200 OK for URL validation` + `[${requestId}] ${webhookRecord.provider} webhook - block not in deployment, returning 200 OK for URL validation` ) return NextResponse.json({ status: 'ok', @@ -458,27 +351,15 @@ export async function findAllWebhooksForPath( return results } -/** - * Resolve {{VARIABLE}} references in a string value - * @param value - String that may contain {{VARIABLE}} references - * @param envVars - Already decrypted environment variables - * @returns String with all {{VARIABLE}} references replaced - */ function resolveEnvVars(value: string, envVars: Record): string { return resolveEnvVarReferences(value, envVars) as string } -/** - * Resolve environment variables in webhook providerConfig - * @param config - Raw providerConfig from database (may contain {{VARIABLE}} refs) - * @param envVars - Already decrypted environment variables - * @returns New object with resolved values (original config is unchanged) - */ function resolveProviderConfigEnvVars( - config: Record, + config: Record, envVars: Record -): Record { - const resolved: Record = {} +): Record { + const resolved: Record = {} for (const [key, value] of Object.entries(config)) { if (typeof value === 'string') { resolved[key] = resolveEnvVars(value, envVars) @@ -490,8 +371,8 @@ function resolveProviderConfigEnvVars( } /** - * Verify webhook provider authentication and signatures - * @returns NextResponse with 401 if auth fails, null if auth passes + * Verify webhook provider authentication and signatures. + * Delegates to the provider handler registry. */ export async function verifyProviderAuth( foundWebhook: any, @@ -500,7 +381,6 @@ export async function verifyProviderAuth( rawBody: string, requestId: string ): Promise { - // Step 1: Fetch and decrypt environment variables for signature verification let decryptedEnvVars: Record = {} try { decryptedEnvVars = await getEffectiveDecryptedEnv( @@ -513,429 +393,20 @@ export async function verifyProviderAuth( }) } - // Step 2: Resolve {{VARIABLE}} references in providerConfig - const rawProviderConfig = (foundWebhook.providerConfig as Record) || {} + const rawProviderConfig = (foundWebhook.providerConfig as Record) || {} const providerConfig = resolveProviderConfigEnvVars(rawProviderConfig, decryptedEnvVars) - if (foundWebhook.provider === 'microsoft-teams') { - if (providerConfig.hmacSecret) { - const authHeader = request.headers.get('authorization') - - if (!authHeader || !authHeader.startsWith('HMAC ')) { - logger.warn( - `[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header` - ) - return new NextResponse('Unauthorized - Missing HMAC signature', { - status: 401, - }) - } - - const isValidSignature = validateMicrosoftTeamsSignature( - providerConfig.hmacSecret, - authHeader, - rawBody - ) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`) - return new NextResponse('Unauthorized - Invalid HMAC signature', { - status: 401, - }) - } - } - } - - // Ashby webhook signature verification (HMAC-SHA256 via Ashby-Signature header) - if (foundWebhook.provider === 'ashby') { - const secretToken = providerConfig.secretToken as string | undefined - - if (secretToken) { - const signature = request.headers.get('ashby-signature') - - if (!signature) { - logger.warn(`[${requestId}] Ashby webhook missing Ashby-Signature header`) - return new NextResponse('Unauthorized - Missing Ashby signature', { - status: 401, - }) - } - - if (!validateAshbySignature(secretToken, signature, rawBody)) { - logger.warn(`[${requestId}] Ashby webhook signature verification failed`) - return new NextResponse('Unauthorized - Invalid Ashby signature', { - status: 401, - }) - } - } - } - - // Provider-specific verification (utils may return a response for some providers) - const providerVerification = verifyProviderWebhook(foundWebhook, request, requestId) - if (providerVerification) { - return providerVerification - } - - // Handle Google Forms shared-secret authentication (Apps Script forwarder) - if (foundWebhook.provider === 'google_forms') { - const expectedToken = providerConfig.token as string | undefined - const secretHeaderName = providerConfig.secretHeaderName as string | undefined - - if (expectedToken) { - let isTokenValid = false - - if (secretHeaderName) { - const headerValue = request.headers.get(secretHeaderName.toLowerCase()) - if (headerValue === expectedToken) { - isTokenValid = true - } - } else { - const authHeader = request.headers.get('authorization') - if (authHeader?.toLowerCase().startsWith('bearer ')) { - const token = authHeader.substring(7) - if (token === expectedToken) { - isTokenValid = true - } - } - } - - if (!isTokenValid) { - logger.warn(`[${requestId}] Google Forms webhook authentication failed`) - return new NextResponse('Unauthorized - Invalid secret', { - status: 401, - }) - } - } - } - - // Twilio Voice webhook signature verification - if (foundWebhook.provider === 'twilio_voice') { - const authToken = providerConfig.authToken as string | undefined - - if (authToken) { - const signature = request.headers.get('x-twilio-signature') - - if (!signature) { - logger.warn(`[${requestId}] Twilio Voice webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Twilio signature', { - status: 401, - }) - } - - let params: Record = {} - try { - if (typeof rawBody === 'string') { - const urlParams = new URLSearchParams(rawBody) - params = Object.fromEntries(urlParams.entries()) - } - } catch (error) { - logger.error( - `[${requestId}] Error parsing Twilio webhook body for signature validation:`, - error - ) - return new NextResponse('Bad Request - Invalid body format', { - status: 400, - }) - } - - const fullUrl = getExternalUrl(request) - const isValidSignature = await validateTwilioSignature(authToken, signature, fullUrl, params) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Twilio Voice signature verification failed`, { - url: fullUrl, - signatureLength: signature.length, - paramsCount: Object.keys(params).length, - authTokenLength: authToken.length, - }) - return new NextResponse('Unauthorized - Invalid Twilio signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'typeform') { - const secret = providerConfig.secret as string | undefined - - if (secret) { - const signature = request.headers.get('Typeform-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Typeform webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Typeform signature', { - status: 401, - }) - } - - const isValidSignature = validateTypeformSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Typeform signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Typeform signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'attio') { - const secret = providerConfig.webhookSecret as string | undefined - - if (!secret) { - logger.debug( - `[${requestId}] Attio webhook ${foundWebhook.id} has no signing secret, skipping signature verification` - ) - } else { - const signature = request.headers.get('Attio-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Attio webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Attio signature', { - status: 401, - }) - } - - const isValidSignature = validateAttioSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Attio signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Attio signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'linear') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('Linear-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Linear webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Linear signature', { - status: 401, - }) - } - - const isValidSignature = validateLinearSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Linear signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Linear signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'circleback') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('x-signature') - - if (!signature) { - logger.warn(`[${requestId}] Circleback webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Circleback signature', { - status: 401, - }) - } - - const isValidSignature = validateCirclebackSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Circleback signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Circleback signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'calcom') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('X-Cal-Signature-256') - - if (!signature) { - logger.warn(`[${requestId}] Cal.com webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Cal.com signature', { - status: 401, - }) - } - - const isValidSignature = validateCalcomSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Cal.com signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Cal.com signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'jira') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('X-Hub-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Jira webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Jira signature', { - status: 401, - }) - } - - const isValidSignature = validateJiraSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Jira signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Jira signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'confluence') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('X-Hub-Signature') - - if (!signature) { - logger.warn(`[${requestId}] Confluence webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Confluence signature', { - status: 401, - }) - } - - const isValidSignature = validateJiraSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Confluence signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Confluence signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'github') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - // GitHub supports both SHA-256 (preferred) and SHA-1 (legacy) - const signature256 = request.headers.get('X-Hub-Signature-256') - const signature1 = request.headers.get('X-Hub-Signature') - const signature = signature256 || signature1 - - if (!signature) { - logger.warn(`[${requestId}] GitHub webhook missing signature header`) - return new NextResponse('Unauthorized - Missing GitHub signature', { - status: 401, - }) - } - - const isValidSignature = validateGitHubSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] GitHub signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - usingSha256: !!signature256, - }) - return new NextResponse('Unauthorized - Invalid GitHub signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'fireflies') { - const secret = providerConfig.webhookSecret as string | undefined - - if (secret) { - const signature = request.headers.get('x-hub-signature') - - if (!signature) { - logger.warn(`[${requestId}] Fireflies webhook missing signature header`) - return new NextResponse('Unauthorized - Missing Fireflies signature', { - status: 401, - }) - } - - const isValidSignature = validateFirefliesSignature(secret, signature, rawBody) - - if (!isValidSignature) { - logger.warn(`[${requestId}] Fireflies signature verification failed`, { - signatureLength: signature.length, - secretLength: secret.length, - }) - return new NextResponse('Unauthorized - Invalid Fireflies signature', { - status: 401, - }) - } - } - } - - if (foundWebhook.provider === 'generic') { - if (providerConfig.requireAuth) { - const configToken = providerConfig.token - const secretHeaderName = providerConfig.secretHeaderName - - if (configToken) { - let isTokenValid = false - - if (secretHeaderName) { - const headerValue = request.headers.get(secretHeaderName.toLowerCase()) - if (headerValue && safeCompare(headerValue, configToken)) { - isTokenValid = true - } - } else { - const authHeader = request.headers.get('authorization') - if (authHeader?.toLowerCase().startsWith('bearer ')) { - const token = authHeader.substring(7) - if (safeCompare(token, configToken)) { - isTokenValid = true - } - } - } - - if (!isTokenValid) { - return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) - } - } else { - return new NextResponse('Unauthorized - Authentication required but not configured', { - status: 401, - }) - } - } + const handler = getProviderHandler(foundWebhook.provider) + if (handler.verifyAuth) { + const authResult = await handler.verifyAuth({ + webhook: foundWebhook, + workflow: foundWorkflow, + request, + rawBody, + requestId, + providerConfig, + }) + if (authResult) return authResult } return null @@ -943,7 +414,6 @@ export async function verifyProviderAuth( /** * Run preprocessing checks for webhook execution - * This replaces the old checkRateLimits and checkUsageLimits functions */ export async function checkWebhookPreprocessing( foundWorkflow: any, @@ -984,20 +454,8 @@ export async function checkWebhookPreprocessing( statusCode: error.statusCode, }) - if (foundWebhook.provider === 'microsoft-teams') { - return { - error: NextResponse.json( - { - type: 'message', - text: error.message, - }, - { status: error.statusCode } - ), - } - } - return { - error: NextResponse.json({ error: error.message }, { status: error.statusCode }), + error: formatProviderErrorResponse(foundWebhook, error.message, error.statusCode), } } @@ -1010,20 +468,8 @@ export async function checkWebhookPreprocessing( } catch (preprocessError) { logger.error(`[${requestId}] Error during webhook preprocessing:`, preprocessError) - if (foundWebhook.provider === 'microsoft-teams') { - return { - error: NextResponse.json( - { - type: 'message', - text: 'Internal error during preprocessing', - }, - { status: 500 } - ), - } - } - return { - error: NextResponse.json({ error: 'Internal error during preprocessing' }, { status: 500 }), + error: formatProviderErrorResponse(foundWebhook, 'Internal error during preprocessing', 500), } } } @@ -1035,188 +481,41 @@ export async function queueWebhookExecution( request: NextRequest, options: WebhookProcessorOptions ): Promise { - try { - // GitHub event filtering for event-specific triggers - if (foundWebhook.provider === 'github') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && triggerId !== 'github_webhook') { - const eventType = request.headers.get('x-github-event') - const action = body.action - - if (!isGitHubEventMatch(triggerId, eventType || '', action, body)) { - logger.debug( - `[${options.requestId}] GitHub event mismatch for trigger ${triggerId}. Event: ${eventType}, Action: ${action}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: eventType, - receivedAction: action, - } - ) - - // Return 200 OK to prevent GitHub from retrying - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) - } - } - } - - // Jira event filtering for event-specific triggers - if (foundWebhook.provider === 'jira') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && triggerId !== 'jira_webhook') { - const webhookEvent = body.webhookEvent as string | undefined + const providerConfig = (foundWebhook.providerConfig as Record) || {} + const handler = getProviderHandler(foundWebhook.provider) - if (!isJiraEventMatch(triggerId, webhookEvent || '', body)) { - logger.debug( - `[${options.requestId}] Jira event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: webhookEvent, - } - ) - - // Return 200 OK to prevent Jira from retrying - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) + try { + if (handler.matchEvent) { + const result = await handler.matchEvent({ + webhook: foundWebhook, + workflow: foundWorkflow, + body, + request, + requestId: options.requestId, + providerConfig, + }) + if (result !== true) { + if (result instanceof NextResponse) { + return result } - } - } - - if (foundWebhook.provider === 'confluence') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && !isConfluencePayloadMatch(triggerId, body)) { - logger.debug( - `[${options.requestId}] Confluence payload mismatch for trigger ${triggerId}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - bodyKeys: Object.keys(body), - } - ) - return NextResponse.json({ - message: 'Payload does not match trigger configuration. Ignoring.', + message: 'Event type does not match trigger configuration. Ignoring.', }) } } - if (foundWebhook.provider === 'attio') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId && triggerId !== 'attio_webhook') { - const { isAttioPayloadMatch, getAttioEvent } = await import('@/triggers/attio/utils') - if (!isAttioPayloadMatch(triggerId, body)) { - const event = getAttioEvent(body) - const eventType = event?.event_type as string | undefined - logger.debug( - `[${options.requestId}] Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: eventType, - bodyKeys: Object.keys(body), - } - ) - return NextResponse.json({ - status: 'skipped', - reason: 'event_type_mismatch', - }) - } - } - } - - if (foundWebhook.provider === 'hubspot') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId?.startsWith('hubspot_')) { - const events = Array.isArray(body) ? body : [body] - const firstEvent = events[0] - - const subscriptionType = firstEvent?.subscriptionType as string | undefined - - if (!isHubSpotContactEventMatch(triggerId, subscriptionType || '')) { - logger.debug( - `[${options.requestId}] HubSpot event mismatch for trigger ${triggerId}. Event: ${subscriptionType}. Skipping execution.`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: subscriptionType, - } - ) - - // Return 200 OK to prevent HubSpot from retrying - return NextResponse.json({ - message: 'Event type does not match trigger configuration. Ignoring.', - }) - } - - logger.info( - `[${options.requestId}] HubSpot event match confirmed for trigger ${triggerId}. Event: ${subscriptionType}`, - { - webhookId: foundWebhook.id, - workflowId: foundWorkflow.id, - triggerId, - receivedEvent: subscriptionType, - } - ) - } - } - const { 'x-sim-idempotency-key': _, ...headers } = Object.fromEntries(request.headers.entries()) - // For Microsoft Teams Graph notifications, extract unique identifiers for idempotency - if ( - foundWebhook.provider === 'microsoft-teams' && - body?.value && - Array.isArray(body.value) && - body.value.length > 0 - ) { - const notification = body.value[0] - const subscriptionId = notification.subscriptionId - const messageId = notification.resourceData?.id - - if (subscriptionId && messageId) { - headers['x-teams-notification-id'] = `${subscriptionId}:${messageId}` - } - } - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - - if (foundWebhook.provider === 'generic') { - const idempotencyField = providerConfig.idempotencyField as string | undefined - if (idempotencyField && body) { - const value = idempotencyField - .split('.') - .reduce((acc: any, key: string) => acc?.[key], body) - if (value !== undefined && value !== null && typeof value !== 'object') { - headers['x-sim-idempotency-key'] = String(value) - } - } + if (handler.enrichHeaders) { + handler.enrichHeaders( + { webhook: foundWebhook, body, requestId: options.requestId, providerConfig }, + headers + ) } const credentialId = providerConfig.credentialId as string | undefined - - // credentialSetId is a direct field on webhook table, not in providerConfig const credentialSetId = foundWebhook.credentialSetId as string | undefined - // Verify billing for credential sets if (credentialSetId) { const billingCheck = await verifyCredentialSetBilling(credentialSetId) if (!billingCheck.valid) { @@ -1355,112 +654,18 @@ export async function queueWebhookExecution( } } - if (foundWebhook.provider === 'microsoft-teams') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - // Chat subscription (Graph API) returns 202 - if (triggerId === 'microsoftteams_chat_subscription') { - return new NextResponse(null, { status: 202 }) - } - - // Channel webhook (outgoing webhook) returns message response - return NextResponse.json({ - type: 'message', - text: 'Sim', - }) - } - - // Slack requires an empty 200 for interactive payloads (view_submission, block_actions, etc.) - // A JSON body like {"message":"..."} is not a recognized response format and causes modal errors - if (foundWebhook.provider === 'slack') { - return new NextResponse(null, { status: 200 }) - } - - // Twilio Voice requires TwiML XML response - if (foundWebhook.provider === 'twilio_voice') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const twimlResponse = (providerConfig.twimlResponse as string | undefined)?.trim() - - // If user provided custom TwiML, convert square brackets to angle brackets and return - if (twimlResponse && twimlResponse.length > 0) { - const convertedTwiml = convertSquareBracketsToTwiML(twimlResponse) - return new NextResponse(convertedTwiml, { - status: 200, - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, - }) - } - - // Default TwiML if none provided - const defaultTwiml = ` - - Your call is being processed. - -` - - return new NextResponse(defaultTwiml, { - status: 200, - headers: { - 'Content-Type': 'text/xml; charset=utf-8', - }, - }) - } - - if (foundWebhook.provider === 'generic' && providerConfig.responseMode === 'custom') { - const rawCode = Number(providerConfig.responseStatusCode) || 200 - const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200 - const responseBody = (providerConfig.responseBody as string | undefined)?.trim() - - if (!responseBody) { - return new NextResponse(null, { status: statusCode }) - } - - try { - const parsed = JSON.parse(responseBody) - return NextResponse.json(parsed, { status: statusCode }) - } catch { - return new NextResponse(responseBody, { - status: statusCode, - headers: { 'Content-Type': 'text/plain' }, - }) - } + const successResponse = handler.formatSuccessResponse?.(providerConfig) ?? null + if (successResponse) { + return successResponse } return NextResponse.json({ message: 'Webhook processed' }) - } catch (error: any) { + } catch (error: unknown) { logger.error(`[${options.requestId}] Failed to queue webhook execution:`, error) - if (foundWebhook.provider === 'microsoft-teams') { - return NextResponse.json( - { - type: 'message', - text: 'Webhook processing failed', - }, - { status: 500 } - ) - } - - if (foundWebhook.provider === 'slack') { - // Return empty 200 to avoid Slack showing an error dialog to the user, - // even though processing failed. The error is already logged above. - return new NextResponse(null, { status: 200 }) - } - - if (foundWebhook.provider === 'twilio_voice') { - const errorTwiml = ` - - We're sorry, but an error occurred processing your call. Please try again later. - -` - - return new NextResponse(errorTwiml, { - status: 200, - headers: { - 'Content-Type': 'text/xml', - }, - }) + const errorResponse = handler.formatQueueErrorResponse?.() ?? null + if (errorResponse) { + return errorResponse } return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 4c5e16ffc1a..227e05753ab 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -1,1978 +1,8 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' import type { NextRequest } from 'next/server' -import { validateAirtableId, validateAlphanumericId } from '@/lib/core/security/input-validation' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { generateId } from '@/lib/core/utils/uuid' -import { - getOAuthToken, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +import { getProviderHandler } from '@/lib/webhooks/providers' -const teamsLogger = createLogger('TeamsSubscription') -const telegramLogger = createLogger('TelegramWebhook') -const airtableLogger = createLogger('AirtableWebhook') -const typeformLogger = createLogger('TypeformWebhook') -const calendlyLogger = createLogger('CalendlyWebhook') -const ashbyLogger = createLogger('AshbyWebhook') -const grainLogger = createLogger('GrainWebhook') -const fathomLogger = createLogger('FathomWebhook') -const lemlistLogger = createLogger('LemlistWebhook') -const webflowLogger = createLogger('WebflowWebhook') -const attioLogger = createLogger('AttioWebhook') -const providerSubscriptionsLogger = createLogger('WebhookProviderSubscriptions') - -function getProviderConfig(webhook: any): Record { - return (webhook.providerConfig as Record) || {} -} - -function getNotificationUrl(webhook: any): string { - return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}` -} - -async function getCredentialOwner( - credentialId: string, - requestId: string -): Promise<{ userId: string; accountId: string } | null> { - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - providerSubscriptionsLogger.warn( - `[${requestId}] Failed to resolve OAuth account for credentialId ${credentialId}` - ) - return null - } - const [credentialRecord] = await db - .select({ userId: account.userId }) - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - - if (!credentialRecord?.userId) { - providerSubscriptionsLogger.warn( - `[${requestId}] Credential owner not found for credentialId ${credentialId}` - ) - return null - } - - return { userId: credentialRecord.userId, accountId: resolved.accountId } -} - -/** - * Create a Microsoft Teams chat subscription - * Throws errors with friendly messages if subscription creation fails - */ -export async function createTeamsSubscription( - request: NextRequest, - webhook: any, - workflow: any, - requestId: string -): Promise { - const config = getProviderConfig(webhook) - - if (config.triggerId !== 'microsoftteams_chat_subscription') { - return undefined - } - - const credentialId = config.credentialId as string | undefined - const chatId = config.chatId as string | undefined - - if (!credentialId) { - teamsLogger.warn( - `[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}` - ) - throw new Error( - 'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.' - ) - } - - if (!chatId) { - teamsLogger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`) - throw new Error( - 'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.' - ) - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded(credentialOwner.accountId, credentialOwner.userId, requestId) - : null - if (!accessToken) { - teamsLogger.error( - `[${requestId}] Failed to get access token for Teams subscription ${webhook.id}` - ) - throw new Error( - 'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.' - ) - } - - const existingSubscriptionId = config.externalSubscriptionId as string | undefined - if (existingSubscriptionId) { - try { - const checkRes = await fetch( - `https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`, - { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } } - ) - if (checkRes.ok) { - teamsLogger.info( - `[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}` - ) - return existingSubscriptionId - } - } catch { - teamsLogger.debug(`[${requestId}] Existing subscription check failed, will create new one`) - } - } - - const notificationUrl = getNotificationUrl(webhook) - const resource = `/chats/${chatId}/messages` - - // Max lifetime: 4230 minutes (~3 days) - Microsoft Graph API limit - const maxLifetimeMinutes = 4230 - const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString() - - const body = { - changeType: 'created,updated', - notificationUrl, - lifecycleNotificationUrl: notificationUrl, - resource, - includeResourceData: false, - expirationDateTime, - clientState: webhook.id, - } - - try { - const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(body), - }) - - const payload = await res.json() - if (!res.ok) { - const errorMessage = - payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error' - teamsLogger.error( - `[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`, - { - status: res.status, - error: payload.error, - } - ) - - let userFriendlyMessage = 'Failed to create Teams subscription' - if (res.status === 401 || res.status === 403) { - userFriendlyMessage = - 'Authentication failed. Please reconnect your Microsoft Teams account and ensure you have the necessary permissions.' - } else if (res.status === 404) { - userFriendlyMessage = - 'Chat not found. Please verify that the Chat ID is correct and that you have access to the specified chat.' - } else if (errorMessage && errorMessage !== 'Unknown Microsoft Graph API error') { - userFriendlyMessage = `Teams error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - teamsLogger.info( - `[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}` - ) - return payload.id as string - } catch (error: any) { - if ( - error instanceof Error && - (error.message.includes('credentials') || - error.message.includes('Chat ID') || - error.message.includes('authenticate')) - ) { - throw error - } - - teamsLogger.error( - `[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`, - error - ) - throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Teams subscription. Please try again.' - ) - } -} - -/** - * Delete a Microsoft Teams chat subscription - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteTeamsSubscription( - webhook: any, - workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - - if (config.triggerId !== 'microsoftteams_chat_subscription') { - return - } - - const externalSubscriptionId = config.externalSubscriptionId as string | undefined - const credentialId = config.credentialId as string | undefined - - if (!externalSubscriptionId || !credentialId) { - teamsLogger.info( - `[${requestId}] No external subscription to delete for webhook ${webhook.id}` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - if (!accessToken) { - teamsLogger.warn( - `[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}` - ) - return - } - - const res = await fetch( - `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, - { - method: 'DELETE', - headers: { Authorization: `Bearer ${accessToken}` }, - } - ) - - if (res.ok || res.status === 404) { - teamsLogger.info( - `[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}` - ) - } else { - const errorBody = await res.text() - teamsLogger.warn( - `[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}` - ) - } - } catch (error) { - teamsLogger.error( - `[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`, - error - ) - } -} - -/** - * Create a Telegram bot webhook - * Throws errors with friendly messages if webhook creation fails - */ -export async function createTelegramWebhook( - request: NextRequest, - webhook: any, - requestId: string -): Promise { - const config = getProviderConfig(webhook) - const botToken = config.botToken as string | undefined - - if (!botToken) { - telegramLogger.warn(`[${requestId}] Missing botToken for Telegram webhook ${webhook.id}`) - throw new Error( - 'Bot token is required to create a Telegram webhook. Please provide a valid Telegram bot token.' - ) - } - - const notificationUrl = getNotificationUrl(webhook) - const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook` - - try { - const telegramResponse = await fetch(telegramApiUrl, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'User-Agent': 'TelegramBot/1.0', - }, - body: JSON.stringify({ url: notificationUrl }), - }) - - const responseBody = await telegramResponse.json() - if (!telegramResponse.ok || !responseBody.ok) { - const errorMessage = - responseBody.description || - `Failed to create Telegram webhook. Status: ${telegramResponse.status}` - telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody }) - - let userFriendlyMessage = 'Failed to create Telegram webhook' - if (telegramResponse.status === 401) { - userFriendlyMessage = - 'Invalid bot token. Please verify that the bot token is correct and try again.' - } else if (responseBody.description) { - userFriendlyMessage = `Telegram error: ${responseBody.description}` - } - - throw new Error(userFriendlyMessage) - } - - telegramLogger.info( - `[${requestId}] Successfully created Telegram webhook for webhook ${webhook.id}` - ) - } catch (error: any) { - if ( - error instanceof Error && - (error.message.includes('Bot token') || error.message.includes('Telegram error')) - ) { - throw error - } - - telegramLogger.error( - `[${requestId}] Error creating Telegram webhook for webhook ${webhook.id}`, - error - ) - throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Telegram webhook. Please try again.' - ) - } -} - -/** - * Delete a Telegram bot webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteTelegramWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const botToken = config.botToken as string | undefined - - if (!botToken) { - telegramLogger.warn( - `[${requestId}] Missing botToken for Telegram webhook deletion ${webhook.id}` - ) - return - } - - const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook` - const telegramResponse = await fetch(telegramApiUrl, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - }) - - const responseBody = await telegramResponse.json() - if (!telegramResponse.ok || !responseBody.ok) { - const errorMessage = - responseBody.description || - `Failed to delete Telegram webhook. Status: ${telegramResponse.status}` - telegramLogger.error(`[${requestId}] ${errorMessage}`, { response: responseBody }) - } else { - telegramLogger.info( - `[${requestId}] Successfully deleted Telegram webhook for webhook ${webhook.id}` - ) - } - } catch (error) { - telegramLogger.error( - `[${requestId}] Error deleting Telegram webhook for webhook ${webhook.id}`, - error - ) - } -} - -/** - * Delete an Airtable webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteAirtableWebhook( - webhook: any, - _workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - const { baseId, externalId } = config as { - baseId?: string - externalId?: string - } - - if (!baseId) { - airtableLogger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion`, { - webhookId: webhook.id, - }) - return - } - - const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') - if (!baseIdValidation.isValid) { - airtableLogger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, { - webhookId: webhook.id, - baseId: baseId.substring(0, 20), - }) - return - } - - const credentialId = config.credentialId as string | undefined - if (!credentialId) { - airtableLogger.warn( - `[${requestId}] Missing credentialId for Airtable webhook deletion ${webhook.id}` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - if (!accessToken) { - airtableLogger.warn( - `[${requestId}] Could not retrieve Airtable access token. Cannot delete webhook in Airtable.`, - { webhookId: webhook.id } - ) - return - } - - let resolvedExternalId: string | undefined = externalId - - if (!resolvedExternalId) { - try { - const expectedNotificationUrl = getNotificationUrl(webhook) - - const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` - const listResp = await fetch(listUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - const listBody = await listResp.json().catch(() => null) - - if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) { - const match = listBody.webhooks.find((w: any) => { - const url: string | undefined = w?.notificationUrl - if (!url) return false - return ( - url === expectedNotificationUrl || - url.endsWith(`/api/webhooks/trigger/${webhook.path}`) - ) - }) - if (match?.id) { - resolvedExternalId = match.id as string - airtableLogger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, { - baseId, - externalId: resolvedExternalId, - }) - } else { - airtableLogger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, { - baseId, - expectedNotificationUrl, - }) - } - } else { - airtableLogger.warn( - `[${requestId}] Failed to list Airtable webhooks to resolve externalId`, - { - baseId, - status: listResp.status, - body: listBody, - } - ) - } - } catch (e: any) { - airtableLogger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, { - error: e?.message, - }) - } - } - - if (!resolvedExternalId) { - airtableLogger.info( - `[${requestId}] Airtable externalId not found; skipping remote deletion`, - { baseId } - ) - return - } - - const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId') - if (!webhookIdValidation.isValid) { - airtableLogger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, { - webhookId: webhook.id, - externalId: resolvedExternalId.substring(0, 20), - }) - return - } - - const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}` - const airtableResponse = await fetch(airtableDeleteUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!airtableResponse.ok) { - let responseBody: any = null - try { - responseBody = await airtableResponse.json() - } catch { - // Ignore parse errors - } - - airtableLogger.warn( - `[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`, - { baseId, externalId: resolvedExternalId, response: responseBody } - ) - } else { - airtableLogger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, { - baseId, - externalId: resolvedExternalId, - }) - } - } catch (error: any) { - airtableLogger.error(`[${requestId}] Error deleting Airtable webhook`, { - webhookId: webhook.id, - error: error.message, - stack: error.stack, - }) - } -} - -/** - * Create a Typeform webhook subscription - * Throws errors with friendly messages if webhook creation fails - */ -export async function createTypeformWebhook( - request: NextRequest, - webhook: any, - requestId: string -): Promise { - const config = getProviderConfig(webhook) - const formId = config.formId as string | undefined - const apiKey = config.apiKey as string | undefined - const webhookTag = config.webhookTag as string | undefined - const secret = config.secret as string | undefined - - if (!formId) { - typeformLogger.warn(`[${requestId}] Missing formId for Typeform webhook ${webhook.id}`) - throw new Error( - 'Form ID is required to create a Typeform webhook. Please provide a valid form ID.' - ) - } - - if (!apiKey) { - typeformLogger.warn(`[${requestId}] Missing apiKey for Typeform webhook ${webhook.id}`) - throw new Error( - 'Personal Access Token is required to create a Typeform webhook. Please provide your Typeform API key.' - ) - } - - const tag = webhookTag || `sim-${webhook.id.substring(0, 8)}` - const notificationUrl = getNotificationUrl(webhook) - - try { - const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` - - const requestBody: Record = { - url: notificationUrl, - enabled: true, - verify_ssl: true, - event_types: { - form_response: true, - }, - } - - if (secret) { - requestBody.secret = secret - } - - const typeformResponse = await fetch(typeformApiUrl, { - method: 'PUT', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!typeformResponse.ok) { - const responseBody = await typeformResponse.json().catch(() => ({})) - const errorMessage = responseBody.description || responseBody.message || 'Unknown error' - - typeformLogger.error(`[${requestId}] Typeform API error: ${errorMessage}`, { - status: typeformResponse.status, - response: responseBody, - }) - - let userFriendlyMessage = 'Failed to create Typeform webhook' - if (typeformResponse.status === 401) { - userFriendlyMessage = - 'Invalid Personal Access Token. Please verify your Typeform API key and try again.' - } else if (typeformResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure you have a Typeform PRO or PRO+ account and the API key has webhook permissions.' - } else if (typeformResponse.status === 404) { - userFriendlyMessage = 'Form not found. Please verify the form ID is correct.' - } else if (responseBody.description || responseBody.message) { - userFriendlyMessage = `Typeform error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await typeformResponse.json() - typeformLogger.info( - `[${requestId}] Successfully created Typeform webhook for webhook ${webhook.id} with tag ${tag}`, - { webhookId: responseBody.id } - ) - - return tag - } catch (error: any) { - if ( - error instanceof Error && - (error.message.includes('Form ID') || - error.message.includes('Personal Access Token') || - error.message.includes('Typeform error')) - ) { - throw error - } - - typeformLogger.error( - `[${requestId}] Error creating Typeform webhook for webhook ${webhook.id}`, - error - ) - throw new Error( - error instanceof Error - ? error.message - : 'Failed to create Typeform webhook. Please try again.' - ) - } -} - -/** - * Delete a Typeform webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteTypeformWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const formId = config.formId as string | undefined - const apiKey = config.apiKey as string | undefined - const webhookTag = config.webhookTag as string | undefined - - if (!formId || !apiKey) { - typeformLogger.warn( - `[${requestId}] Missing formId or apiKey for Typeform webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const tag = webhookTag || `sim-${webhook.id.substring(0, 8)}` - const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` - - const typeformResponse = await fetch(typeformApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!typeformResponse.ok && typeformResponse.status !== 404) { - typeformLogger.warn( - `[${requestId}] Failed to delete Typeform webhook (non-fatal): ${typeformResponse.status}` - ) - } else { - typeformLogger.info(`[${requestId}] Successfully deleted Typeform webhook with tag ${tag}`) - } - } catch (error) { - typeformLogger.warn(`[${requestId}] Error deleting Typeform webhook (non-fatal)`, error) - } -} - -/** - * Delete a Calendly webhook subscription - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteCalendlyWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - calendlyLogger.warn( - `[${requestId}] Missing apiKey for Calendly webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - calendlyLogger.warn( - `[${requestId}] Missing externalId for Calendly webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const calendlyApiUrl = `https://api.calendly.com/webhook_subscriptions/${externalId}` - - const calendlyResponse = await fetch(calendlyApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - }, - }) - - if (!calendlyResponse.ok && calendlyResponse.status !== 404) { - const responseBody = await calendlyResponse.json().catch(() => ({})) - calendlyLogger.warn( - `[${requestId}] Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`, - { response: responseBody } - ) - } else { - calendlyLogger.info( - `[${requestId}] Successfully deleted Calendly webhook subscription ${externalId}` - ) - } - } catch (error) { - calendlyLogger.warn(`[${requestId}] Error deleting Calendly webhook (non-fatal)`, error) - } -} - -/** - * Delete a Grain webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteGrainWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - grainLogger.warn( - `[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - grainLogger.warn( - `[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}` - - const grainResponse = await fetch(grainApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - }) - - if (!grainResponse.ok && grainResponse.status !== 404) { - const responseBody = await grainResponse.json().catch(() => ({})) - grainLogger.warn( - `[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`, - { response: responseBody } - ) - } else { - grainLogger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`) - } - } catch (error) { - grainLogger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error) - } -} - -/** - * Delete a Fathom webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteFathomWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - fathomLogger.warn( - `[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - fathomLogger.warn( - `[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100) - if (!idValidation.isValid) { - fathomLogger.warn( - `[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}` - - const fathomResponse = await fetch(fathomApiUrl, { - method: 'DELETE', - headers: { - 'X-Api-Key': apiKey, - 'Content-Type': 'application/json', - }, - }) - - if (!fathomResponse.ok && fathomResponse.status !== 404) { - fathomLogger.warn( - `[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}` - ) - } else { - fathomLogger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`) - } - } catch (error) { - fathomLogger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error) - } -} - -/** - * Delete a Lemlist webhook - * Don't fail webhook deletion if cleanup fails - */ -export async function deleteLemlistWebhook(webhook: any, requestId: string): Promise { - try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - lemlistLogger.warn( - `[${requestId}] Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const authString = Buffer.from(`:${apiKey}`).toString('base64') - - const deleteById = async (id: string) => { - const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50) - if (!validation.isValid) { - lemlistLogger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, { - id: id.substring(0, 30), - }) - return - } - - const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}` - const lemlistResponse = await fetch(lemlistApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Basic ${authString}`, - }, - }) - - if (!lemlistResponse.ok && lemlistResponse.status !== 404) { - const responseBody = await lemlistResponse.json().catch(() => ({})) - lemlistLogger.warn( - `[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, - { response: responseBody } - ) - } else { - lemlistLogger.info(`[${requestId}] Successfully deleted Lemlist webhook ${id}`) - } - } - - if (externalId) { - await deleteById(externalId) - return - } - - const notificationUrl = getNotificationUrl(webhook) - const listResponse = await fetch('https://api.lemlist.com/api/hooks', { - method: 'GET', - headers: { - Authorization: `Basic ${authString}`, - }, - }) - - if (!listResponse.ok) { - lemlistLogger.warn( - `[${requestId}] Failed to list Lemlist webhooks for cleanup ${webhook.id}`, - { status: listResponse.status } - ) - return - } - - const listBody = await listResponse.json().catch(() => null) - const hooks: Array> = Array.isArray(listBody) - ? listBody - : listBody?.hooks || listBody?.data || [] - const matches = hooks.filter((hook) => { - const targetUrl = hook?.targetUrl || hook?.target_url || hook?.url - return typeof targetUrl === 'string' && targetUrl === notificationUrl - }) - - if (matches.length === 0) { - lemlistLogger.info(`[${requestId}] Lemlist webhook not found for cleanup ${webhook.id}`, { - notificationUrl, - }) - return - } - - for (const hook of matches) { - const hookId = hook?._id || hook?.id - if (typeof hookId === 'string' && hookId.length > 0) { - await deleteById(hookId) - } - } - } catch (error) { - lemlistLogger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) - } -} - -export async function deleteWebflowWebhook( - webhook: any, - _workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - const siteId = config.siteId as string | undefined - const externalId = config.externalId as string | undefined - - if (!siteId) { - webflowLogger.warn( - `[${requestId}] Missing siteId for Webflow webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - webflowLogger.warn( - `[${requestId}] Missing externalId for Webflow webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) - if (!siteIdValidation.isValid) { - webflowLogger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, { - webhookId: webhook.id, - siteId: siteId.substring(0, 30), - }) - return - } - - const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100) - if (!webhookIdValidation.isValid) { - webflowLogger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, { - webhookId: webhook.id, - externalId: externalId.substring(0, 30), - }) - return - } - - const credentialId = config.credentialId as string | undefined - if (!credentialId) { - webflowLogger.warn( - `[${requestId}] Missing credentialId for Webflow webhook deletion ${webhook.id}` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - if (!accessToken) { - webflowLogger.warn( - `[${requestId}] Could not retrieve Webflow access token. Cannot delete webhook.`, - { webhookId: webhook.id } - ) - return - } - - const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks/${externalId}` - - const webflowResponse = await fetch(webflowApiUrl, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - accept: 'application/json', - }, - }) - - if (!webflowResponse.ok && webflowResponse.status !== 404) { - const responseBody = await webflowResponse.json().catch(() => ({})) - webflowLogger.warn( - `[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, - { response: responseBody } - ) - } else { - webflowLogger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`) - } - } catch (error) { - webflowLogger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error) - } -} - -export async function createAttioWebhookSubscription( - userId: string, - webhookData: any, - requestId: string -): Promise<{ externalId: string; webhookSecret: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { triggerId, credentialId } = providerConfig || {} - - if (!credentialId) { - attioLogger.warn(`[${requestId}] Missing credentialId for Attio webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' - ) - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - - if (!accessToken) { - attioLogger.warn( - `[${requestId}] Could not retrieve Attio access token for user ${userId}. Cannot create webhook.` - ) - throw new Error( - 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const { TRIGGER_EVENT_MAP } = await import('@/triggers/attio/utils') - - let subscriptions: Array<{ event_type: string; filter: null }> = [] - if (triggerId === 'attio_webhook') { - const allEvents = new Set() - for (const events of Object.values(TRIGGER_EVENT_MAP)) { - for (const event of events) { - allEvents.add(event) - } - } - subscriptions = Array.from(allEvents).map((event_type) => ({ event_type, filter: null })) - } else { - const events = TRIGGER_EVENT_MAP[triggerId] - if (!events || events.length === 0) { - attioLogger.warn(`[${requestId}] No event types mapped for trigger ${triggerId}`, { - webhookId: webhookData.id, - }) - throw new Error(`Unknown Attio trigger type: ${triggerId}`) - } - subscriptions = events.map((event_type) => ({ event_type, filter: null })) - } - - const requestBody = { - data: { - target_url: notificationUrl, - subscriptions, - }, - } - - const attioResponse = await fetch('https://api.attio.com/v2/webhooks', { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!attioResponse.ok) { - const errorBody = await attioResponse.json().catch(() => ({})) - attioLogger.error( - `[${requestId}] Failed to create webhook in Attio for webhook ${webhookData.id}. Status: ${attioResponse.status}`, - { response: errorBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Attio' - if (attioResponse.status === 401) { - userFriendlyMessage = 'Attio authentication failed. Please reconnect your Attio account.' - } else if (attioResponse.status === 403) { - userFriendlyMessage = - 'Attio access denied. Please ensure your integration has webhook permissions.' - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await attioResponse.json() - const data = responseBody.data || responseBody - const webhookId = data.id?.webhook_id || data.webhook_id || data.id - const secret = data.secret - - if (!webhookId) { - attioLogger.error( - `[${requestId}] Attio webhook created but no webhook_id returned for webhook ${webhookData.id}`, - { response: responseBody } - ) - throw new Error('Attio webhook creation succeeded but no webhook ID was returned') - } - - if (!secret) { - attioLogger.warn( - `[${requestId}] Attio webhook created but no secret returned for webhook ${webhookData.id}. Signature verification will be skipped.`, - { response: responseBody } - ) - } - - attioLogger.info( - `[${requestId}] Successfully created webhook in Attio for webhook ${webhookData.id}.`, - { - attioWebhookId: webhookId, - targetUrl: notificationUrl, - subscriptionCount: subscriptions.length, - status: data.status, - } - ) - - return { externalId: webhookId, webhookSecret: secret || '' } - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error) - attioLogger.error( - `[${requestId}] Exception during Attio webhook creation for webhook ${webhookData.id}.`, - { message } - ) - throw error - } -} - -export async function deleteAttioWebhook( - webhook: any, - _workflow: any, - requestId: string -): Promise { - try { - const config = getProviderConfig(webhook) - const externalId = config.externalId as string | undefined - const credentialId = config.credentialId as string | undefined - - if (!externalId) { - attioLogger.warn( - `[${requestId}] Missing externalId for Attio webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!credentialId) { - attioLogger.warn( - `[${requestId}] Missing credentialId for Attio webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const credentialOwner = await getCredentialOwner(credentialId, requestId) - const accessToken = credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - - if (!accessToken) { - attioLogger.warn( - `[${requestId}] Could not retrieve Attio access token. Cannot delete webhook.`, - { webhookId: webhook.id } - ) - return - } - - const attioResponse = await fetch(`https://api.attio.com/v2/webhooks/${externalId}`, { - method: 'DELETE', - headers: { - Authorization: `Bearer ${accessToken}`, - }, - }) - - if (!attioResponse.ok && attioResponse.status !== 404) { - const responseBody = await attioResponse.json().catch(() => ({})) - attioLogger.warn( - `[${requestId}] Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`, - { response: responseBody } - ) - } else { - attioLogger.info(`[${requestId}] Successfully deleted Attio webhook ${externalId}`) - } - } catch (error) { - attioLogger.warn(`[${requestId}] Error deleting Attio webhook (non-fatal)`, error) - } -} - -export async function createGrainWebhookSubscription( - _request: NextRequest, - webhookData: any, - requestId: string -): Promise<{ id: string; eventTypes: string[] } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, viewId } = providerConfig || {} - - if (!apiKey) { - grainLogger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.' - ) - } - - if (!viewId) { - grainLogger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, { - webhookId: webhookData.id, - triggerId, - }) - throw new Error( - 'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.' - ) - } - - const actionMap: Record> = { - grain_item_added: ['added'], - grain_item_updated: ['updated'], - grain_recording_created: ['added'], - grain_recording_updated: ['updated'], - grain_highlight_created: ['added'], - grain_highlight_updated: ['updated'], - grain_story_created: ['added'], - } - - const eventTypeMap: Record = { - grain_webhook: [], - grain_item_added: [], - grain_item_updated: [], - grain_recording_created: ['recording_added'], - grain_recording_updated: ['recording_updated'], - grain_highlight_created: ['highlight_added'], - grain_highlight_updated: ['highlight_updated'], - grain_story_created: ['story_added'], - } - - const actions = actionMap[triggerId] ?? [] - const eventTypes = eventTypeMap[triggerId] ?? [] - - if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) { - grainLogger.warn( - `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`, - { - webhookId: webhookData.id, - } - ) - } - - grainLogger.info(`[${requestId}] Creating Grain webhook`, { - triggerId, - viewId, - actions, - eventTypes, - webhookId: webhookData.id, - }) - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const grainApiUrl = 'https://api.grain.com/_/public-api/hooks' - - const requestBody: Record = { - version: 2, - hook_url: notificationUrl, - view_id: viewId, - } - if (actions.length > 0) { - requestBody.actions = actions - } - - const grainResponse = await fetch(grainApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await grainResponse.json() - - if (!grainResponse.ok || responseBody.error || responseBody.errors) { - const errorMessage = - responseBody.errors?.detail || - responseBody.error?.message || - responseBody.error || - responseBody.message || - 'Unknown Grain API error' - grainLogger.error( - `[${requestId}] Failed to create webhook in Grain for webhook ${webhookData.id}. Status: ${grainResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Grain' - if (grainResponse.status === 401) { - userFriendlyMessage = - 'Invalid Grain API Key. Please verify your Personal Access Token is correct.' - } else if (grainResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Grain API Key has appropriate permissions.' - } else if (errorMessage && errorMessage !== 'Unknown Grain API error') { - userFriendlyMessage = `Grain error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const grainWebhookId = responseBody.id - - if (!grainWebhookId) { - grainLogger.error( - `[${requestId}] Grain webhook creation response missing id for webhook ${webhookData.id}.`, - { - response: responseBody, - } - ) - throw new Error( - 'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.' - ) - } - - grainLogger.info( - `[${requestId}] Successfully created webhook in Grain for webhook ${webhookData.id}.`, - { - grainWebhookId, - eventTypes, - } - ) - - return { id: grainWebhookId, eventTypes } - } catch (error: any) { - grainLogger.error( - `[${requestId}] Exception during Grain webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createFathomWebhookSubscription( - _request: NextRequest, - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { - apiKey, - triggerId, - triggeredFor, - includeSummary, - includeTranscript, - includeActionItems, - includeCrmMatches, - } = providerConfig || {} - - if (!apiKey) { - fathomLogger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Fathom API Key is required. Please provide your API key in the trigger configuration.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const triggeredForValue = triggeredFor || 'my_recordings' - - const toBool = (val: unknown, fallback: boolean): boolean => { - if (val === undefined) return fallback - return val === true || val === 'true' - } - - const requestBody: Record = { - destination_url: notificationUrl, - triggered_for: [triggeredForValue], - include_summary: toBool(includeSummary, true), - include_transcript: toBool(includeTranscript, false), - include_action_items: toBool(includeActionItems, false), - include_crm_matches: toBool(includeCrmMatches, false), - } - - fathomLogger.info(`[${requestId}] Creating Fathom webhook`, { - triggerId, - triggeredFor: triggeredForValue, - webhookId: webhookData.id, - }) - - const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', { - method: 'POST', - headers: { - 'X-Api-Key': apiKey, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await fathomResponse.json().catch(() => ({})) - - if (!fathomResponse.ok) { - const errorMessage = - (responseBody as Record).message || - (responseBody as Record).error || - 'Unknown Fathom API error' - fathomLogger.error( - `[${requestId}] Failed to create webhook in Fathom for webhook ${webhookData.id}. Status: ${fathomResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Fathom' - if (fathomResponse.status === 401) { - userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.' - } else if (fathomResponse.status === 400) { - userFriendlyMessage = `Fathom error: ${errorMessage}` - } else if (errorMessage && errorMessage !== 'Unknown Fathom API error') { - userFriendlyMessage = `Fathom error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - if (!responseBody.id) { - fathomLogger.error( - `[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhookData.id}.` - ) - throw new Error('Fathom webhook created but no ID returned. Please try again.') - } - - fathomLogger.info( - `[${requestId}] Successfully created webhook in Fathom for webhook ${webhookData.id}.`, - { - fathomWebhookId: responseBody.id, - } - ) - - return { id: responseBody.id } - } catch (error: any) { - fathomLogger.error( - `[${requestId}] Exception during Fathom webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createLemlistWebhookSubscription( - webhookData: any, - requestId: string -): Promise<{ id: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId, campaignId } = providerConfig || {} - - if (!apiKey) { - lemlistLogger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.' - ) - } - - const eventTypeMap: Record = { - lemlist_email_replied: 'emailsReplied', - lemlist_linkedin_replied: 'linkedinReplied', - lemlist_interested: 'interested', - lemlist_not_interested: 'notInterested', - lemlist_email_opened: 'emailsOpened', - lemlist_email_clicked: 'emailsClicked', - lemlist_email_bounced: 'emailsBounced', - lemlist_email_sent: 'emailsSent', - lemlist_webhook: undefined, - } - - const eventType = eventTypeMap[triggerId] - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - const authString = Buffer.from(`:${apiKey}`).toString('base64') - - lemlistLogger.info(`[${requestId}] Creating Lemlist webhook`, { - triggerId, - eventType, - hasCampaignId: !!campaignId, - webhookId: webhookData.id, - }) - - const lemlistApiUrl = 'https://api.lemlist.com/api/hooks' - - const requestBody: Record = { - targetUrl: notificationUrl, - } - - if (eventType) { - requestBody.type = eventType - } - - if (campaignId) { - requestBody.campaignId = campaignId - } - - const lemlistResponse = await fetch(lemlistApiUrl, { - method: 'POST', - headers: { - Authorization: `Basic ${authString}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await lemlistResponse.json() - - if (!lemlistResponse.ok || responseBody.error) { - const errorMessage = responseBody.message || responseBody.error || 'Unknown Lemlist API error' - lemlistLogger.error( - `[${requestId}] Failed to create webhook in Lemlist for webhook ${webhookData.id}. Status: ${lemlistResponse.status}`, - { message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist' - if (lemlistResponse.status === 401) { - userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.' - } else if (lemlistResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Lemlist API Key has appropriate permissions.' - } else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') { - userFriendlyMessage = `Lemlist error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - lemlistLogger.info( - `[${requestId}] Successfully created webhook in Lemlist for webhook ${webhookData.id}.`, - { - lemlistWebhookId: responseBody._id, - } - ) - - return { id: responseBody._id } - } catch (error: any) { - lemlistLogger.error( - `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createAirtableWebhookSubscription( - userId: string, - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { baseId, tableId, includeCellValuesInFieldIds, credentialId } = providerConfig || {} - - if (!baseId || !tableId) { - airtableLogger.warn( - `[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, - { - webhookId: webhookData.id, - } - ) - throw new Error( - 'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.' - ) - } - - const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') - if (!baseIdValidation.isValid) { - throw new Error(baseIdValidation.error) - } - - const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId') - if (!tableIdValidation.isValid) { - throw new Error(tableIdValidation.error) - } - - const credentialOwner = credentialId ? await getCredentialOwner(credentialId, requestId) : null - const accessToken = credentialId - ? credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - : await getOAuthToken(userId, 'airtable') - if (!accessToken) { - airtableLogger.warn( - `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` - ) - throw new Error( - 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` - - const specification: any = { - options: { - filters: { - dataTypes: ['tableData'], - recordChangeScope: tableId, - }, - }, - } - - if (includeCellValuesInFieldIds === 'all') { - specification.options.includes = { - includeCellValuesInFieldIds: 'all', - } - } - - const requestBody: any = { - notificationUrl: notificationUrl, - specification: specification, - } - - const airtableResponse = await fetch(airtableApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await airtableResponse.json() - - if (!airtableResponse.ok || responseBody.error) { - const errorMessage = - responseBody.error?.message || responseBody.error || 'Unknown Airtable API error' - const errorType = responseBody.error?.type - airtableLogger.error( - `[${requestId}] Failed to create webhook in Airtable for webhook ${webhookData.id}. Status: ${airtableResponse.status}`, - { type: errorType, message: errorMessage, response: responseBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Airtable' - if (airtableResponse.status === 404) { - userFriendlyMessage = - 'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.' - } else if (errorMessage && errorMessage !== 'Unknown Airtable API error') { - userFriendlyMessage = `Airtable error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - airtableLogger.info( - `[${requestId}] Successfully created webhook in Airtable for webhook ${webhookData.id}.`, - { - airtableWebhookId: responseBody.id, - } - ) - return responseBody.id - } catch (error: any) { - airtableLogger.error( - `[${requestId}] Exception during Airtable webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createCalendlyWebhookSubscription( - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { apiKey, organization, triggerId } = providerConfig || {} - - if (!apiKey) { - calendlyLogger.warn(`[${requestId}] Missing apiKey for Calendly webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error( - 'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.' - ) - } - - if (!organization) { - calendlyLogger.warn( - `[${requestId}] Missing organization URI for Calendly webhook creation.`, - { - webhookId: webhookData.id, - } - ) - throw new Error( - 'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.' - ) - } - - if (!triggerId) { - calendlyLogger.warn(`[${requestId}] Missing triggerId for Calendly webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Trigger ID is required to create Calendly webhook') - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const eventTypeMap: Record = { - calendly_invitee_created: ['invitee.created'], - calendly_invitee_canceled: ['invitee.canceled'], - calendly_routing_form_submitted: ['routing_form_submission.created'], - calendly_webhook: ['invitee.created', 'invitee.canceled', 'routing_form_submission.created'], - } - - const events = eventTypeMap[triggerId] || ['invitee.created'] - - const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions' - - const requestBody = { - url: notificationUrl, - events, - organization, - scope: 'organization', - } - - const calendlyResponse = await fetch(calendlyApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - if (!calendlyResponse.ok) { - const errorBody = await calendlyResponse.json().catch(() => ({})) - const errorMessage = errorBody.message || errorBody.title || 'Unknown Calendly API error' - calendlyLogger.error( - `[${requestId}] Failed to create webhook in Calendly for webhook ${webhookData.id}. Status: ${calendlyResponse.status}`, - { response: errorBody } - ) - - let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' - if (calendlyResponse.status === 401) { - userFriendlyMessage = - 'Calendly authentication failed. Please verify your Personal Access Token is correct.' - } else if (calendlyResponse.status === 403) { - userFriendlyMessage = - 'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.' - } else if (calendlyResponse.status === 404) { - userFriendlyMessage = - 'Calendly organization not found. Please verify the Organization URI is correct.' - } else if (errorMessage && errorMessage !== 'Unknown Calendly API error') { - userFriendlyMessage = `Calendly error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const responseBody = await calendlyResponse.json() - const webhookUri = responseBody.resource?.uri - - if (!webhookUri) { - calendlyLogger.error( - `[${requestId}] Calendly webhook created but no webhook URI returned for webhook ${webhookData.id}`, - { response: responseBody } - ) - throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') - } - - const webhookId = webhookUri.split('/').pop() - - if (!webhookId) { - calendlyLogger.error( - `[${requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, - { - response: responseBody, - } - ) - throw new Error('Failed to extract webhook ID from Calendly response') - } - - calendlyLogger.info( - `[${requestId}] Successfully created webhook in Calendly for webhook ${webhookData.id}.`, - { - calendlyWebhookUri: webhookUri, - calendlyWebhookId: webhookId, - } - ) - return webhookId - } catch (error: any) { - calendlyLogger.error( - `[${requestId}] Exception during Calendly webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} - -export async function createWebflowWebhookSubscription( - userId: string, - webhookData: any, - requestId: string -): Promise { - try { - const { path, providerConfig } = webhookData - const { siteId, triggerId, collectionId, formName, credentialId } = providerConfig || {} - - if (!siteId) { - webflowLogger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Site ID is required to create Webflow webhook') - } - - const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) - if (!siteIdValidation.isValid) { - throw new Error(siteIdValidation.error) - } - - if (!triggerId) { - webflowLogger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { - webhookId: webhookData.id, - }) - throw new Error('Trigger type is required to create Webflow webhook') - } - - const credentialOwner = credentialId ? await getCredentialOwner(credentialId, requestId) : null - const accessToken = credentialId - ? credentialOwner - ? await refreshAccessTokenIfNeeded( - credentialOwner.accountId, - credentialOwner.userId, - requestId - ) - : null - : await getOAuthToken(userId, 'webflow') - if (!accessToken) { - webflowLogger.warn( - `[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.` - ) - throw new Error( - 'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.' - ) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - - const triggerTypeMap: Record = { - webflow_collection_item_created: 'collection_item_created', - webflow_collection_item_changed: 'collection_item_changed', - webflow_collection_item_deleted: 'collection_item_deleted', - webflow_form_submission: 'form_submission', - } - - const webflowTriggerType = triggerTypeMap[triggerId] - if (!webflowTriggerType) { - webflowLogger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { - webhookId: webhookData.id, - }) - throw new Error(`Invalid Webflow trigger type: ${triggerId}`) - } - - const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks` - - const requestBody: any = { - triggerType: webflowTriggerType, - url: notificationUrl, - } - - // Note: Webflow API only supports 'filter' for form_submission triggers. - if (formName && webflowTriggerType === 'form_submission') { - requestBody.filter = { - name: formName, - } - } - - const webflowResponse = await fetch(webflowApiUrl, { - method: 'POST', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - accept: 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await webflowResponse.json() - - if (!webflowResponse.ok || responseBody.error) { - const errorMessage = responseBody.message || responseBody.error || 'Unknown Webflow API error' - webflowLogger.error( - `[${requestId}] Failed to create webhook in Webflow for webhook ${webhookData.id}. Status: ${webflowResponse.status}`, - { message: errorMessage, response: responseBody } - ) - throw new Error(errorMessage) - } - - webflowLogger.info( - `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookData.id}.`, - { - webflowWebhookId: responseBody.id || responseBody._id, - } - ) - - return responseBody.id || responseBody._id - } catch (error: any) { - webflowLogger.error( - `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error - } -} +const logger = createLogger('WebhookProviderSubscriptions') type ExternalSubscriptionResult = { updatedProviderConfig: Record @@ -1986,21 +16,6 @@ type RecreateCheckInput = { nextConfig: Record } -/** Providers that create external webhook subscriptions */ -const PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS = new Set([ - 'airtable', - 'ashby', - 'attio', - 'calendly', - 'fathom', - 'webflow', - 'typeform', - 'grain', - 'lemlist', - 'telegram', - 'microsoft-teams', -]) - /** System-managed fields that shouldn't trigger recreation */ const SYSTEM_MANAGED_FIELDS = new Set([ 'externalId', @@ -2020,14 +35,16 @@ export function shouldRecreateExternalWebhookSubscription({ previousConfig, nextConfig, }: RecreateCheckInput): boolean { + const hasSubscription = (provider: string) => { + const handler = getProviderHandler(provider) + return Boolean(handler.createSubscription) + } + if (previousProvider !== nextProvider) { - return ( - PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(previousProvider) || - PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(nextProvider) - ) + return hasSubscription(previousProvider) || hasSubscription(nextProvider) } - if (!PROVIDERS_WITH_EXTERNAL_SUBSCRIPTIONS.has(nextProvider)) { + if (!hasSubscription(nextProvider)) { return false } @@ -2052,287 +69,60 @@ export function shouldRecreateExternalWebhookSubscription({ export async function createExternalWebhookSubscription( request: NextRequest, - webhookData: any, - workflow: any, + webhookData: Record, + workflow: Record, userId: string, requestId: string ): Promise { const provider = webhookData.provider as string const providerConfig = (webhookData.providerConfig as Record) || {} - let updatedProviderConfig = providerConfig - let externalSubscriptionCreated = false + const handler = getProviderHandler(provider) - if (provider === 'ashby') { - const result = await createAshbyWebhookSubscription(webhookData, requestId) - if (result) { - updatedProviderConfig = { - ...updatedProviderConfig, - externalId: result.id, - secretToken: result.secretToken, - } - externalSubscriptionCreated = true - } - } else if (provider === 'airtable') { - const externalId = await createAirtableWebhookSubscription(userId, webhookData, requestId) - if (externalId) { - updatedProviderConfig = { ...updatedProviderConfig, externalId } - externalSubscriptionCreated = true - } - } else if (provider === 'attio') { - const result = await createAttioWebhookSubscription(userId, webhookData, requestId) - if (result) { - updatedProviderConfig = { - ...updatedProviderConfig, - externalId: result.externalId, - webhookSecret: result.webhookSecret, - } - externalSubscriptionCreated = true - } - } else if (provider === 'calendly') { - const externalId = await createCalendlyWebhookSubscription(webhookData, requestId) - if (externalId) { - updatedProviderConfig = { ...updatedProviderConfig, externalId } - externalSubscriptionCreated = true - } - } else if (provider === 'microsoft-teams') { - const subscriptionId = await createTeamsSubscription(request, webhookData, workflow, requestId) - if (subscriptionId) { - updatedProviderConfig = { ...updatedProviderConfig, externalSubscriptionId: subscriptionId } - externalSubscriptionCreated = true - } - } else if (provider === 'telegram') { - await createTelegramWebhook(request, webhookData, requestId) - externalSubscriptionCreated = true - } else if (provider === 'webflow') { - const externalId = await createWebflowWebhookSubscription(userId, webhookData, requestId) - if (externalId) { - updatedProviderConfig = { ...updatedProviderConfig, externalId } - externalSubscriptionCreated = true - } - } else if (provider === 'typeform') { - const usedTag = await createTypeformWebhook(request, webhookData, requestId) - if (!updatedProviderConfig.webhookTag && usedTag) { - updatedProviderConfig = { ...updatedProviderConfig, webhookTag: usedTag } - } - externalSubscriptionCreated = true - } else if (provider === 'fathom') { - const result = await createFathomWebhookSubscription(request, webhookData, requestId) - if (result) { - updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } - externalSubscriptionCreated = true - } - } else if (provider === 'grain') { - const result = await createGrainWebhookSubscription(request, webhookData, requestId) - if (result) { - updatedProviderConfig = { - ...updatedProviderConfig, - externalId: result.id, - eventTypes: result.eventTypes, - } - externalSubscriptionCreated = true - } - } else if (provider === 'lemlist') { - const result = await createLemlistWebhookSubscription(webhookData, requestId) - if (result) { - updatedProviderConfig = { ...updatedProviderConfig, externalId: result.id } - externalSubscriptionCreated = true - } + if (!handler.createSubscription) { + return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false } } - return { updatedProviderConfig, externalSubscriptionCreated } -} + const result = await handler.createSubscription({ + webhook: webhookData, + workflow, + userId, + requestId, + request, + }) -/** - * Clean up external webhook subscriptions for a webhook - * Handles Airtable, Attio, Teams, Telegram, Typeform, Calendly, Grain, and Lemlist cleanup - * Don't fail deletion if cleanup fails - */ -export async function cleanupExternalWebhook( - webhook: any, - workflow: any, - requestId: string -): Promise { - if (webhook.provider === 'ashby') { - await deleteAshbyWebhook(webhook, requestId) - } else if (webhook.provider === 'airtable') { - await deleteAirtableWebhook(webhook, workflow, requestId) - } else if (webhook.provider === 'attio') { - await deleteAttioWebhook(webhook, workflow, requestId) - } else if (webhook.provider === 'microsoft-teams') { - await deleteTeamsSubscription(webhook, workflow, requestId) - } else if (webhook.provider === 'telegram') { - await deleteTelegramWebhook(webhook, requestId) - } else if (webhook.provider === 'typeform') { - await deleteTypeformWebhook(webhook, requestId) - } else if (webhook.provider === 'calendly') { - await deleteCalendlyWebhook(webhook, requestId) - } else if (webhook.provider === 'webflow') { - await deleteWebflowWebhook(webhook, workflow, requestId) - } else if (webhook.provider === 'fathom') { - await deleteFathomWebhook(webhook, requestId) - } else if (webhook.provider === 'grain') { - await deleteGrainWebhook(webhook, requestId) - } else if (webhook.provider === 'lemlist') { - await deleteLemlistWebhook(webhook, requestId) + if (!result) { + return { updatedProviderConfig: providerConfig, externalSubscriptionCreated: false } + } + + return { + updatedProviderConfig: { ...providerConfig, ...result.providerConfigUpdates }, + externalSubscriptionCreated: true, } } /** - * Creates a webhook subscription in Ashby via webhook.create API. - * Ashby uses Basic Auth and one webhook per event type (webhookType). + * Clean up external webhook subscriptions for a webhook. + * Errors are swallowed — cleanup failure should not block webhook deletion. */ -export async function createAshbyWebhookSubscription( - webhookData: any, +export async function cleanupExternalWebhook( + webhook: Record, + workflow: Record, requestId: string -): Promise<{ id: string; secretToken: string } | undefined> { - try { - const { path, providerConfig } = webhookData - const { apiKey, triggerId } = providerConfig || {} - - if (!apiKey) { - throw new Error( - 'Ashby API Key is required. Please provide your API Key with apiKeysWrite permission in the trigger configuration.' - ) - } - - if (!triggerId) { - throw new Error('Trigger ID is required to create Ashby webhook.') - } - - const webhookTypeMap: Record = { - ashby_application_submit: 'applicationSubmit', - ashby_candidate_stage_change: 'candidateStageChange', - ashby_candidate_hire: 'candidateHire', - ashby_candidate_delete: 'candidateDelete', - ashby_job_create: 'jobCreate', - ashby_offer_create: 'offerCreate', - } - - const webhookType = webhookTypeMap[triggerId] - if (!webhookType) { - throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`) - } - - const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` - const authString = Buffer.from(`${apiKey}:`).toString('base64') - - ashbyLogger.info(`[${requestId}] Creating Ashby webhook`, { - triggerId, - webhookType, - webhookId: webhookData.id, - }) - - const secretToken = generateId() - - const requestBody: Record = { - requestUrl: notificationUrl, - webhookType, - secretToken, - } - - const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', { - method: 'POST', - headers: { - Authorization: `Basic ${authString}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify(requestBody), - }) - - const responseBody = await ashbyResponse.json().catch(() => ({})) - - if (!ashbyResponse.ok || !responseBody.success) { - const errorMessage = - responseBody.errorInfo?.message || responseBody.message || 'Unknown Ashby API error' - - let userFriendlyMessage = 'Failed to create webhook subscription in Ashby' - if (ashbyResponse.status === 401) { - userFriendlyMessage = - 'Invalid Ashby API Key. Please verify your API Key is correct and has apiKeysWrite permission.' - } else if (ashbyResponse.status === 403) { - userFriendlyMessage = - 'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.' - } else if (errorMessage && errorMessage !== 'Unknown Ashby API error') { - userFriendlyMessage = `Ashby error: ${errorMessage}` - } - - throw new Error(userFriendlyMessage) - } - - const externalId = responseBody.results?.id - if (!externalId) { - throw new Error('Ashby webhook creation succeeded but no webhook ID was returned') - } +): Promise { + const provider = webhook.provider as string + const handler = getProviderHandler(provider) - ashbyLogger.info( - `[${requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${webhookData.id}` - ) - return { id: externalId, secretToken } - } catch (error: any) { - ashbyLogger.error( - `[${requestId}] Exception during Ashby webhook creation for webhook ${webhookData.id}.`, - { - message: error.message, - stack: error.stack, - } - ) - throw error + if (!handler.deleteSubscription) { + return } -} -/** - * Deletes an Ashby webhook subscription via webhook.delete API. - * Ashby uses POST with webhookId in the body (not DELETE method). - */ -export async function deleteAshbyWebhook(webhook: any, requestId: string): Promise { try { - const config = getProviderConfig(webhook) - const apiKey = config.apiKey as string | undefined - const externalId = config.externalId as string | undefined - - if (!apiKey) { - ashbyLogger.warn( - `[${requestId}] Missing apiKey for Ashby webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - if (!externalId) { - ashbyLogger.warn( - `[${requestId}] Missing externalId for Ashby webhook deletion ${webhook.id}, skipping cleanup` - ) - return - } - - const authString = Buffer.from(`${apiKey}:`).toString('base64') - - const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.delete', { - method: 'POST', - headers: { - Authorization: `Basic ${authString}`, - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ webhookId: externalId }), - }) - - if (ashbyResponse.ok) { - await ashbyResponse.body?.cancel() - ashbyLogger.info( - `[${requestId}] Successfully deleted Ashby webhook subscription ${externalId}` - ) - } else if (ashbyResponse.status === 404) { - await ashbyResponse.body?.cancel() - ashbyLogger.info( - `[${requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` - ) - } else { - const responseBody = await ashbyResponse.json().catch(() => ({})) - ashbyLogger.warn( - `[${requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`, - { response: responseBody } - ) - } + await handler.deleteSubscription({ webhook, workflow, requestId }) } catch (error) { - ashbyLogger.warn(`[${requestId}] Error deleting Ashby webhook (non-fatal)`, error) + logger.warn(`[${requestId}] Error cleaning up external webhook (non-fatal)`, { + provider, + webhookId: webhook.id, + error: error instanceof Error ? error.message : String(error), + }) } } diff --git a/apps/sim/lib/webhooks/provider-utils.ts b/apps/sim/lib/webhooks/provider-utils.ts deleted file mode 100644 index c475d1205e3..00000000000 --- a/apps/sim/lib/webhooks/provider-utils.ts +++ /dev/null @@ -1,93 +0,0 @@ -/** - * Provider-specific unique identifier extractors for webhook idempotency - */ - -function extractSlackIdentifier(body: any): string | null { - if (body.event_id) { - return body.event_id - } - - if (body.event?.ts && body.team_id) { - return `${body.team_id}:${body.event.ts}` - } - - return null -} - -function extractTwilioIdentifier(body: any): string | null { - return body.MessageSid || body.CallSid || null -} - -function extractStripeIdentifier(body: any): string | null { - if (body.id && body.object === 'event') { - return body.id - } - return null -} - -function extractHubSpotIdentifier(body: any): string | null { - if (Array.isArray(body) && body.length > 0 && body[0]?.eventId) { - return String(body[0].eventId) - } - return null -} - -function extractLinearIdentifier(body: any): string | null { - if (body.action && body.data?.id) { - return `${body.action}:${body.data.id}` - } - return null -} - -function extractJiraIdentifier(body: any): string | null { - if (body.webhookEvent && (body.issue?.id || body.project?.id)) { - return `${body.webhookEvent}:${body.issue?.id || body.project?.id}` - } - return null -} - -function extractMicrosoftTeamsIdentifier(body: any): string | null { - if (body.value && Array.isArray(body.value) && body.value.length > 0) { - const notification = body.value[0] - if (notification.subscriptionId && notification.resourceData?.id) { - return `${notification.subscriptionId}:${notification.resourceData.id}` - } - } - return null -} - -function extractAirtableIdentifier(body: any): string | null { - if (body.cursor && typeof body.cursor === 'string') { - return body.cursor - } - return null -} - -function extractGrainIdentifier(body: any): string | null { - if (body.type && body.data?.id) { - return `${body.type}:${body.data.id}` - } - return null -} - -const PROVIDER_EXTRACTORS: Record string | null> = { - slack: extractSlackIdentifier, - twilio: extractTwilioIdentifier, - twilio_voice: extractTwilioIdentifier, - stripe: extractStripeIdentifier, - hubspot: extractHubSpotIdentifier, - linear: extractLinearIdentifier, - jira: extractJiraIdentifier, - 'microsoft-teams': extractMicrosoftTeamsIdentifier, - airtable: extractAirtableIdentifier, - grain: extractGrainIdentifier, -} - -export function extractProviderIdentifierFromBody(provider: string, body: any): string | null { - if (!body || typeof body !== 'object') { - return null - } - - const extractor = PROVIDER_EXTRACTORS[provider] - return extractor ? extractor(body) : null -} diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts new file mode 100644 index 00000000000..80fecf73854 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/airtable.ts @@ -0,0 +1,760 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { validateAirtableId } from '@/lib/core/security/input-validation' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { + getCredentialOwner, + getNotificationUrl, + getProviderConfig, +} from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { + getOAuthToken, + refreshAccessTokenIfNeeded, + resolveOAuthAccountId, +} from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Airtable') + +interface AirtableChange { + tableId: string + recordId: string + changeType: 'created' | 'updated' + changedFields: Record + previousFields?: Record +} + +interface AirtableTableChanges { + createdRecordsById?: Record }> + changedRecordsById?: Record< + string, + { + current?: { cellValuesByFieldId?: Record } + previous?: { cellValuesByFieldId?: Record } + } + > + destroyedRecordIds?: string[] +} + +/** + * Process Airtable payloads + */ +async function fetchAndProcessAirtablePayloads( + webhookData: Record, + workflowData: Record, + requestId: string // Original request ID from the ping, used for the final execution log +) { + // Logging handles all error logging + let currentCursor: number | null = null + let mightHaveMore = true + let payloadsFetched = 0 + let apiCallCount = 0 + // Use a Map to consolidate changes per record ID + const consolidatedChangesMap = new Map() + // Capture raw payloads from Airtable for exposure to workflows + const allPayloads = [] + const localProviderConfig = { + ...((webhookData.providerConfig as Record) || {}), + } as Record + + try { + const baseId = localProviderConfig.baseId + const airtableWebhookId = localProviderConfig.externalId + + if (!baseId || !airtableWebhookId) { + logger.error( + `[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.` + ) + return + } + + const credentialId = localProviderConfig.credentialId as string | undefined + if (!credentialId) { + logger.error( + `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` + ) + return + } + + const resolvedAirtable = await resolveOAuthAccountId(credentialId) + if (!resolvedAirtable) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Airtable webhook` + ) + return + } + + let ownerUserId: string | null = null + try { + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedAirtable.accountId)) + .limit(1) + ownerUserId = rows.length ? rows[0].userId : null + } catch (_e) { + ownerUserId = null + } + + if (!ownerUserId) { + logger.error( + `[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}` + ) + return + } + + const storedCursor = localProviderConfig.externalWebhookCursor + + if (storedCursor === undefined || storedCursor === null) { + logger.info( + `[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...` + ) + localProviderConfig.externalWebhookCursor = null + + try { + await db + .update(webhook) + .set({ + providerConfig: { + ...localProviderConfig, + externalWebhookCursor: null, + }, + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookData.id as string)) + + localProviderConfig.externalWebhookCursor = null + logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) + } catch (initError: unknown) { + const err = initError as Error + logger.error(`[${requestId}] Failed to initialize cursor in DB`, { + webhookId: webhookData.id, + error: err.message, + stack: err.stack, + }) + } + } + + if (storedCursor && typeof storedCursor === 'number') { + currentCursor = storedCursor + } else { + currentCursor = null + } + + let accessToken: string | null = null + try { + accessToken = await refreshAccessTokenIfNeeded( + resolvedAirtable.accountId, + ownerUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` + ) + throw new Error('Airtable access token not found.') + } + } catch (tokenError: unknown) { + const err = tokenError as Error + logger.error( + `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, + { + error: err.message, + stack: err.stack, + credentialId, + } + ) + return + } + + const airtableApiBase = 'https://api.airtable.com/v0' + + while (mightHaveMore) { + apiCallCount++ + // Safety break + if (apiCallCount > 10) { + mightHaveMore = false + break + } + + const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads` + const queryParams = new URLSearchParams() + if (currentCursor !== null) { + queryParams.set('cursor', currentCursor.toString()) + } + const fullUrl = `${apiUrl}?${queryParams.toString()}` + + try { + const fetchStartTime = Date.now() + const response = await fetch(fullUrl, { + method: 'GET', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }) + + const responseBody = await response.json() + + if (!response.ok || responseBody.error) { + const errorMessage = + responseBody.error?.message || + responseBody.error || + `Airtable API error Status ${response.status}` + logger.error( + `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, + { + webhookId: webhookData.id, + status: response.status, + error: errorMessage, + } + ) + // Error logging handled by logging session + mightHaveMore = false + break + } + + const receivedPayloads = responseBody.payloads || [] + + if (receivedPayloads.length > 0) { + payloadsFetched += receivedPayloads.length + // Keep the raw payloads for later exposure to the workflow + for (const p of receivedPayloads) { + allPayloads.push(p) + } + let changeCount = 0 + for (const payload of receivedPayloads) { + if (payload.changedTablesById) { + for (const [tableId, tableChangesUntyped] of Object.entries( + payload.changedTablesById + )) { + const tableChanges = tableChangesUntyped as AirtableTableChanges + + if (tableChanges.createdRecordsById) { + const createdCount = Object.keys(tableChanges.createdRecordsById).length + changeCount += createdCount + + for (const [recordId, recordData] of Object.entries( + tableChanges.createdRecordsById + )) { + const existingChange = consolidatedChangesMap.get(recordId) + if (existingChange) { + // Record was created and possibly updated within the same batch + existingChange.changedFields = { + ...existingChange.changedFields, + ...(recordData.cellValuesByFieldId || {}), + } + // Keep changeType as 'created' if it started as created + } else { + // New creation + consolidatedChangesMap.set(recordId, { + tableId: tableId, + recordId: recordId, + changeType: 'created', + changedFields: recordData.cellValuesByFieldId || {}, + }) + } + } + } + + // Handle updated records + if (tableChanges.changedRecordsById) { + const updatedCount = Object.keys(tableChanges.changedRecordsById).length + changeCount += updatedCount + + for (const [recordId, recordData] of Object.entries( + tableChanges.changedRecordsById + )) { + const existingChange = consolidatedChangesMap.get(recordId) + const currentFields = recordData.current?.cellValuesByFieldId || {} + + if (existingChange) { + // Existing record was updated again + existingChange.changedFields = { + ...existingChange.changedFields, + ...currentFields, + } + // Ensure type is 'updated' if it was previously 'created' + existingChange.changeType = 'updated' + // Do not update previousFields again + } else { + // First update for this record in the batch + const newChange: AirtableChange = { + tableId: tableId, + recordId: recordId, + changeType: 'updated', + changedFields: currentFields, + } + if (recordData.previous?.cellValuesByFieldId) { + newChange.previousFields = recordData.previous.cellValuesByFieldId + } + consolidatedChangesMap.set(recordId, newChange) + } + } + } + // TODO: Handle deleted records (`destroyedRecordIds`) if needed + } + } + } + } + + const nextCursor = responseBody.cursor + mightHaveMore = responseBody.mightHaveMore || false + + if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) { + currentCursor = nextCursor + + // Follow exactly the old implementation - use awaited update instead of parallel + const updatedConfig = { + ...localProviderConfig, + externalWebhookCursor: currentCursor, + } + try { + // Force a complete object update to ensure consistency in serverless env + await db + .update(webhook) + .set({ + providerConfig: updatedConfig, // Use full object + updatedAt: new Date(), + }) + .where(eq(webhook.id, webhookData.id as string)) + + localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too + } catch (dbError: unknown) { + const err = dbError as Error + logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { + webhookId: webhookData.id, + cursor: currentCursor, + error: err.message, + }) + // Error logging handled by logging session + mightHaveMore = false + throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly + } + } else if (!nextCursor || typeof nextCursor !== 'number') { + logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, { + webhookId: webhookData.id, + apiCall: apiCallCount, + receivedCursor: nextCursor, + }) + mightHaveMore = false + } else if (nextCursor === currentCursor) { + mightHaveMore = false // Explicitly stop if cursor hasn't changed + } + } catch (fetchError: unknown) { + logger.error( + `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, + fetchError + ) + // Error logging handled by logging session + mightHaveMore = false + break + } + } + // Convert map values to array for final processing + const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) + logger.info( + `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` + ) + + if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { + try { + // Build input exposing raw payloads and consolidated changes + const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null + const input: Record = { + payloads: allPayloads, + latestPayload, + // Consolidated, simplified changes for convenience + airtableChanges: finalConsolidatedChanges, + // Include webhook metadata for resolver fallbacks + webhook: { + data: { + provider: 'airtable', + providerConfig: webhookData.providerConfig, + payload: latestPayload, + }, + }, + } + + // CRITICAL EXECUTION TRACE POINT + logger.info( + `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, + { + workflowId: workflowData.id, + recordCount: finalConsolidatedChanges.length, + timestamp: new Date().toISOString(), + firstRecordId: finalConsolidatedChanges[0]?.recordId || 'none', + } + ) + + // Return the processed input for the trigger.dev task to handle + logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { + workflowId: workflowData.id, + recordCount: finalConsolidatedChanges.length, + rawPayloadCount: allPayloads.length, + timestamp: new Date().toISOString(), + }) + + return input + } catch (processingError: unknown) { + const err = processingError as Error + logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { + workflowId: workflowData.id, + error: err.message, + stack: err.stack, + timestamp: new Date().toISOString(), + }) + + throw processingError + } + } else { + // DEBUG: Log when no changes are found + logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { + workflowId: workflowData.id, + apiCallCount, + webhookId: webhookData.id, + }) + } + } catch (error) { + // Catch any unexpected errors during the setup/polling logic itself + logger.error( + `[${requestId}] Unexpected error during asynchronous Airtable payload processing task`, + { + webhookId: webhookData.id, + workflowId: workflowData.id, + error: (error as Error).message, + } + ) + // Error logging handled by logging session + } +} + +export const airtableHandler: WebhookProviderHandler = { + async createSubscription({ + webhook: webhookRecord, + workflow, + userId, + requestId, + }: SubscriptionContext): Promise { + try { + const { path, providerConfig } = webhookRecord as Record + const config = (providerConfig as Record) || {} + const { baseId, tableId, includeCellValuesInFieldIds, credentialId } = config as { + baseId?: string + tableId?: string + includeCellValuesInFieldIds?: string + credentialId?: string + } + + if (!baseId || !tableId) { + logger.warn(`[${requestId}] Missing baseId or tableId for Airtable webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error( + 'Base ID and Table ID are required to create Airtable webhook. Please provide valid Airtable base and table IDs.' + ) + } + + const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') + if (!baseIdValidation.isValid) { + throw new Error(baseIdValidation.error) + } + + const tableIdValidation = validateAirtableId(tableId, 'tbl', 'tableId') + if (!tableIdValidation.isValid) { + throw new Error(tableIdValidation.error) + } + + const credentialOwner = credentialId + ? await getCredentialOwner(credentialId, requestId) + : null + const accessToken = credentialId + ? credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + : await getOAuthToken(userId, 'airtable') + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Airtable access token for user ${userId}. Cannot create webhook in Airtable.` + ) + throw new Error( + 'Airtable account connection required. Please connect your Airtable account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const airtableApiUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` + + const specification: Record = { + options: { + filters: { + dataTypes: ['tableData'], + recordChangeScope: tableId, + }, + }, + } + + if (includeCellValuesInFieldIds === 'all') { + ;(specification.options as Record).includes = { + includeCellValuesInFieldIds: 'all', + } + } + + const requestBody: Record = { + notificationUrl: notificationUrl, + specification: specification, + } + + const airtableResponse = await fetch(airtableApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await airtableResponse.json() + + if (!airtableResponse.ok || responseBody.error) { + const errorMessage = + responseBody.error?.message || responseBody.error || 'Unknown Airtable API error' + const errorType = responseBody.error?.type + logger.error( + `[${requestId}] Failed to create webhook in Airtable for webhook ${webhookRecord.id}. Status: ${airtableResponse.status}`, + { type: errorType, message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Airtable' + if (airtableResponse.status === 404) { + userFriendlyMessage = + 'Airtable base or table not found. Please verify that the Base ID and Table ID are correct and that you have access to them.' + } else if (errorMessage && errorMessage !== 'Unknown Airtable API error') { + userFriendlyMessage = `Airtable error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + logger.info( + `[${requestId}] Successfully created webhook in Airtable for webhook ${webhookRecord.id}.`, + { + airtableWebhookId: responseBody.id, + } + ) + return { providerConfigUpdates: { externalId: responseBody.id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Airtable webhook creation for webhook ${webhookRecord.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const { baseId, externalId } = config as { + baseId?: string + externalId?: string + } + + if (!baseId) { + logger.warn(`[${requestId}] Missing baseId for Airtable webhook deletion`, { + webhookId: webhookRecord.id, + }) + return + } + + const baseIdValidation = validateAirtableId(baseId, 'app', 'baseId') + if (!baseIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Airtable base ID format, skipping deletion`, { + webhookId: webhookRecord.id, + baseId: baseId.substring(0, 20), + }) + return + } + + const credentialId = config.credentialId as string | undefined + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for Airtable webhook deletion ${webhookRecord.id}` + ) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Airtable access token. Cannot delete webhook in Airtable.`, + { webhookId: webhookRecord.id } + ) + return + } + + let resolvedExternalId: string | undefined = externalId + + if (!resolvedExternalId) { + try { + const expectedNotificationUrl = getNotificationUrl(webhookRecord) + + const listUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks` + const listResp = await fetch(listUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + const listBody = await listResp.json().catch(() => null) + + if (listResp.ok && listBody && Array.isArray(listBody.webhooks)) { + const match = listBody.webhooks.find((w: Record) => { + const url: string | undefined = w?.notificationUrl as string | undefined + if (!url) return false + return ( + url === expectedNotificationUrl || + url.endsWith(`/api/webhooks/trigger/${webhookRecord.path}`) + ) + }) + if (match?.id) { + resolvedExternalId = match.id as string + logger.info(`[${requestId}] Resolved Airtable externalId by listing webhooks`, { + baseId, + externalId: resolvedExternalId, + }) + } else { + logger.warn(`[${requestId}] Could not resolve Airtable externalId from list`, { + baseId, + expectedNotificationUrl, + }) + } + } else { + logger.warn(`[${requestId}] Failed to list Airtable webhooks to resolve externalId`, { + baseId, + status: listResp.status, + body: listBody, + }) + } + } catch (e: unknown) { + logger.warn(`[${requestId}] Error attempting to resolve Airtable externalId`, { + error: (e as Error)?.message, + }) + } + } + + if (!resolvedExternalId) { + logger.info(`[${requestId}] Airtable externalId not found; skipping remote deletion`, { + baseId, + }) + return + } + + const webhookIdValidation = validateAirtableId(resolvedExternalId, 'ach', 'webhookId') + if (!webhookIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Airtable webhook ID format, skipping deletion`, { + webhookId: webhookRecord.id, + externalId: resolvedExternalId.substring(0, 20), + }) + return + } + + const airtableDeleteUrl = `https://api.airtable.com/v0/bases/${baseId}/webhooks/${resolvedExternalId}` + const airtableResponse = await fetch(airtableDeleteUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!airtableResponse.ok) { + let responseBody: unknown = null + try { + responseBody = await airtableResponse.json() + } catch { + // Ignore parse errors + } + + logger.warn( + `[${requestId}] Failed to delete Airtable webhook in Airtable. Status: ${airtableResponse.status}`, + { baseId, externalId: resolvedExternalId, response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Airtable webhook in Airtable`, { + baseId, + externalId: resolvedExternalId, + }) + } + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Error deleting Airtable webhook`, { + webhookId: webhookRecord.id, + error: err.message, + stack: err.stack, + }) + } + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (typeof obj.cursor === 'string') { + return obj.cursor + } + return null + }, + + async formatInput({ webhook, workflow, requestId }: FormatInputContext) { + logger.info(`[${requestId}] Processing Airtable webhook via fetchAndProcessAirtablePayloads`) + + const webhookData = { + id: webhook.id, + provider: webhook.provider, + providerConfig: webhook.providerConfig, + } + + const mockWorkflow = { + id: workflow.id, + userId: workflow.userId, + } + + const airtableInput = await fetchAndProcessAirtablePayloads( + webhookData, + mockWorkflow, + requestId + ) + + if (airtableInput) { + logger.info(`[${requestId}] Executing workflow with Airtable changes`) + return { input: airtableInput } + } + + logger.info(`[${requestId}] No Airtable changes to process`) + return { input: null, skip: { message: 'No Airtable changes to process' } } + }, +} diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts new file mode 100644 index 00000000000..ce044495009 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/ashby.ts @@ -0,0 +1,208 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import { generateId } from '@/lib/core/utils/uuid' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Ashby') + +function validateAshbySignature(secretToken: string, signature: string, body: string): boolean { + try { + if (!secretToken || !signature || !body) { + return false + } + if (!signature.startsWith('sha256=')) { + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Ashby signature:', error) + return false + } +} + +export const ashbyHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + ...((b.data as Record) || {}), + action: b.action, + data: b.data || {}, + }, + } + }, + + verifyAuth: createHmacVerifier({ + configKey: 'secretToken', + headerName: 'ashby-signature', + validateFn: validateAshbySignature, + providerLabel: 'Ashby', + }), + + async createSubscription(ctx: SubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const { apiKey, triggerId } = providerConfig as { + apiKey?: string + triggerId?: string + } + + if (!apiKey) { + throw new Error( + 'Ashby API Key is required. Please provide your API Key with apiKeysWrite permission in the trigger configuration.' + ) + } + + if (!triggerId) { + throw new Error('Trigger ID is required to create Ashby webhook.') + } + + const webhookTypeMap: Record = { + ashby_application_submit: 'applicationSubmit', + ashby_candidate_stage_change: 'candidateStageChange', + ashby_candidate_hire: 'candidateHire', + ashby_candidate_delete: 'candidateDelete', + ashby_job_create: 'jobCreate', + ashby_offer_create: 'offerCreate', + } + + const webhookType = webhookTypeMap[triggerId] + if (!webhookType) { + throw new Error(`Unknown Ashby triggerId: ${triggerId}. Add it to webhookTypeMap.`) + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + const authString = Buffer.from(`${apiKey}:`).toString('base64') + + logger.info(`[${ctx.requestId}] Creating Ashby webhook`, { + triggerId, + webhookType, + webhookId: ctx.webhook.id, + }) + + const secretToken = generateId() + + const requestBody: Record = { + requestUrl: notificationUrl, + webhookType, + secretToken, + } + + const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.create', { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await ashbyResponse.json().catch(() => ({}))) as Record + + if (!ashbyResponse.ok || !responseBody.success) { + const errorInfo = responseBody.errorInfo as Record | undefined + const errorMessage = + errorInfo?.message || (responseBody.message as string) || 'Unknown Ashby API error' + + let userFriendlyMessage = 'Failed to create webhook subscription in Ashby' + if (ashbyResponse.status === 401) { + userFriendlyMessage = + 'Invalid Ashby API Key. Please verify your API Key is correct and has apiKeysWrite permission.' + } else if (ashbyResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Ashby API Key has the apiKeysWrite permission.' + } else if (errorMessage && errorMessage !== 'Unknown Ashby API error') { + userFriendlyMessage = `Ashby error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const results = responseBody.results as Record | undefined + const externalId = results?.id as string | undefined + if (!externalId) { + throw new Error('Ashby webhook creation succeeded but no webhook ID was returned') + } + + logger.info( + `[${ctx.requestId}] Successfully created Ashby webhook subscription ${externalId} for webhook ${ctx.webhook.id}` + ) + return { providerConfigUpdates: { externalId, secretToken } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${ctx.requestId}] Exception during Ashby webhook creation for webhook ${ctx.webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${ctx.requestId}] Missing apiKey for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${ctx.requestId}] Missing externalId for Ashby webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + const authString = Buffer.from(`${apiKey}:`).toString('base64') + + const ashbyResponse = await fetch('https://api.ashbyhq.com/webhook.delete', { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ webhookId: externalId }), + }) + + if (ashbyResponse.ok) { + await ashbyResponse.body?.cancel() + logger.info( + `[${ctx.requestId}] Successfully deleted Ashby webhook subscription ${externalId}` + ) + } else if (ashbyResponse.status === 404) { + await ashbyResponse.body?.cancel() + logger.info( + `[${ctx.requestId}] Ashby webhook ${externalId} not found during deletion (already removed)` + ) + } else { + const responseBody = await ashbyResponse.json().catch(() => ({})) + logger.warn( + `[${ctx.requestId}] Failed to delete Ashby webhook (non-fatal): ${ashbyResponse.status}`, + { response: responseBody } + ) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Ashby webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts new file mode 100644 index 00000000000..883d979334f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/attio.ts @@ -0,0 +1,366 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Attio') + +function validateAttioSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Attio signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Attio signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating Attio signature:', error) + return false + } +} + +export const attioHandler: WebhookProviderHandler = { + verifyAuth({ webhook, request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + + if (!secret) { + logger.debug( + `[${requestId}] Attio webhook ${webhook.id as string} has no signing secret, skipping signature verification` + ) + } else { + const signature = request.headers.get('Attio-Signature') + + if (!signature) { + logger.warn(`[${requestId}] Attio webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Attio signature', { + status: 401, + }) + } + + const isValidSignature = validateAttioSignature(secret, signature, rawBody) + + if (!isValidSignature) { + logger.warn(`[${requestId}] Attio signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + }) + return new NextResponse('Unauthorized - Invalid Attio signature', { + status: 401, + }) + } + } + + return null + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'attio_webhook') { + const { isAttioPayloadMatch, getAttioEvent } = await import('@/triggers/attio/utils') + if (!isAttioPayloadMatch(triggerId, obj)) { + const event = getAttioEvent(obj) + const eventType = event?.event_type as string | undefined + logger.debug( + `[${requestId}] Attio event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: eventType, + bodyKeys: Object.keys(obj), + } + ) + return NextResponse.json({ + status: 'skipped', + reason: 'event_type_mismatch', + }) + } + } + + return true + }, + + async createSubscription({ + webhook: webhookRecord, + workflow, + userId, + requestId, + }: SubscriptionContext): Promise { + try { + const { path, providerConfig } = webhookRecord as Record + const config = (providerConfig as Record) || {} + const { triggerId, credentialId } = config as { + triggerId?: string + credentialId?: string + } + + if (!credentialId) { + logger.warn(`[${requestId}] Missing credentialId for Attio webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error( + 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' + ) + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Attio access token for user ${userId}. Cannot create webhook.` + ) + throw new Error( + 'Attio account connection required. Please connect your Attio account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const { TRIGGER_EVENT_MAP } = await import('@/triggers/attio/utils') + + let subscriptions: Array<{ event_type: string; filter: null }> = [] + if (triggerId === 'attio_webhook') { + const allEvents = new Set() + for (const events of Object.values(TRIGGER_EVENT_MAP)) { + for (const event of events) { + allEvents.add(event) + } + } + subscriptions = Array.from(allEvents).map((event_type) => ({ event_type, filter: null })) + } else { + const events = TRIGGER_EVENT_MAP[triggerId as string] + if (!events || events.length === 0) { + logger.warn(`[${requestId}] No event types mapped for trigger ${triggerId}`, { + webhookId: webhookRecord.id, + }) + throw new Error(`Unknown Attio trigger type: ${triggerId}`) + } + subscriptions = events.map((event_type) => ({ event_type, filter: null })) + } + + const requestBody = { + data: { + target_url: notificationUrl, + subscriptions, + }, + } + + const attioResponse = await fetch('https://api.attio.com/v2/webhooks', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!attioResponse.ok) { + const errorBody = await attioResponse.json().catch(() => ({})) + logger.error( + `[${requestId}] Failed to create webhook in Attio for webhook ${webhookRecord.id}. Status: ${attioResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Attio' + if (attioResponse.status === 401) { + userFriendlyMessage = 'Attio authentication failed. Please reconnect your Attio account.' + } else if (attioResponse.status === 403) { + userFriendlyMessage = + 'Attio access denied. Please ensure your integration has webhook permissions.' + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = await attioResponse.json() + const data = responseBody.data || responseBody + const webhookId = data.id?.webhook_id || data.webhook_id || data.id + const secret = data.secret + + if (!webhookId) { + logger.error( + `[${requestId}] Attio webhook created but no webhook_id returned for webhook ${webhookRecord.id}`, + { response: responseBody } + ) + throw new Error('Attio webhook creation succeeded but no webhook ID was returned') + } + + if (!secret) { + logger.warn( + `[${requestId}] Attio webhook created but no secret returned for webhook ${webhookRecord.id}. Signature verification will be skipped.`, + { response: responseBody } + ) + } + + logger.info( + `[${requestId}] Successfully created webhook in Attio for webhook ${webhookRecord.id}.`, + { + attioWebhookId: webhookId, + targetUrl: notificationUrl, + subscriptionCount: subscriptions.length, + status: data.status, + } + ) + + return { providerConfigUpdates: { externalId: webhookId, webhookSecret: secret || '' } } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error) + logger.error( + `[${requestId}] Exception during Attio webhook creation for webhook ${webhookRecord.id}.`, + { message } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const externalId = config.externalId as string | undefined + const credentialId = config.credentialId as string | undefined + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for Attio webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Attio access token. Cannot delete webhook.`, + { webhookId: webhookRecord.id } + ) + return + } + + const attioResponse = await fetch(`https://api.attio.com/v2/webhooks/${externalId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }) + + if (!attioResponse.ok && attioResponse.status !== 404) { + const responseBody = await attioResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Attio webhook (non-fatal): ${attioResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Attio webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Attio webhook (non-fatal)`, error) + } + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { + extractAttioRecordData, + extractAttioRecordUpdatedData, + extractAttioRecordMergedData, + extractAttioNoteData, + extractAttioTaskData, + extractAttioCommentData, + extractAttioListEntryData, + extractAttioListEntryUpdatedData, + extractAttioListData, + extractAttioWorkspaceMemberData, + extractAttioGenericData, + } = await import('@/triggers/attio/utils') + + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + + if (triggerId === 'attio_record_updated') { + return { input: extractAttioRecordUpdatedData(b) } + } + if (triggerId === 'attio_record_merged') { + return { input: extractAttioRecordMergedData(b) } + } + if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') { + return { input: extractAttioRecordData(b) } + } + if (triggerId?.startsWith('attio_note_')) { + return { input: extractAttioNoteData(b) } + } + if (triggerId?.startsWith('attio_task_')) { + return { input: extractAttioTaskData(b) } + } + if (triggerId?.startsWith('attio_comment_')) { + return { input: extractAttioCommentData(b) } + } + if (triggerId === 'attio_list_entry_updated') { + return { input: extractAttioListEntryUpdatedData(b) } + } + if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') { + return { input: extractAttioListEntryData(b) } + } + if ( + triggerId === 'attio_list_created' || + triggerId === 'attio_list_updated' || + triggerId === 'attio_list_deleted' + ) { + return { input: extractAttioListData(b) } + } + if (triggerId === 'attio_workspace_member_created') { + return { input: extractAttioWorkspaceMemberData(b) } + } + return { input: extractAttioGenericData(b) } + }, +} diff --git a/apps/sim/lib/webhooks/providers/calcom.ts b/apps/sim/lib/webhooks/providers/calcom.ts new file mode 100644 index 00000000000..b018b16f581 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/calcom.ts @@ -0,0 +1,47 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Calcom') + +function validateCalcomSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Cal.com signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + let providedSignature: string + if (signature.startsWith('sha256=')) { + providedSignature = signature.substring(7) + } else { + providedSignature = signature + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Cal.com signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Cal.com signature:', error) + return false + } +} + +export const calcomHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Cal-Signature-256', + validateFn: validateCalcomSignature, + providerLabel: 'Cal.com', + }), +} diff --git a/apps/sim/lib/webhooks/providers/calendly.ts b/apps/sim/lib/webhooks/providers/calendly.ts new file mode 100644 index 00000000000..7fcca4a8e8f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/calendly.ts @@ -0,0 +1,211 @@ +import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Calendly') + +export const calendlyHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + event: b.event, + created_at: b.created_at, + created_by: b.created_by, + payload: b.payload, + }, + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + try { + const providerConfig = getProviderConfig(ctx.webhook) + const { apiKey, organization, triggerId } = providerConfig as { + apiKey?: string + organization?: string + triggerId?: string + } + + if (!apiKey) { + logger.warn(`[${ctx.requestId}] Missing apiKey for Calendly webhook creation.`, { + webhookId: ctx.webhook.id, + }) + throw new Error( + 'Personal Access Token is required to create Calendly webhook. Please provide your Calendly Personal Access Token.' + ) + } + + if (!organization) { + logger.warn(`[${ctx.requestId}] Missing organization URI for Calendly webhook creation.`, { + webhookId: ctx.webhook.id, + }) + throw new Error( + 'Organization URI is required to create Calendly webhook. Please provide your Organization URI from the "Get Current User" operation.' + ) + } + + if (!triggerId) { + logger.warn(`[${ctx.requestId}] Missing triggerId for Calendly webhook creation.`, { + webhookId: ctx.webhook.id, + }) + throw new Error('Trigger ID is required to create Calendly webhook') + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + + const eventTypeMap: Record = { + calendly_invitee_created: ['invitee.created'], + calendly_invitee_canceled: ['invitee.canceled'], + calendly_routing_form_submitted: ['routing_form_submission.created'], + calendly_webhook: [ + 'invitee.created', + 'invitee.canceled', + 'routing_form_submission.created', + ], + } + + const events = eventTypeMap[triggerId] || ['invitee.created'] + + const calendlyApiUrl = 'https://api.calendly.com/webhook_subscriptions' + + const requestBody = { + url: notificationUrl, + events, + organization, + scope: 'organization', + } + + const calendlyResponse = await fetch(calendlyApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!calendlyResponse.ok) { + const errorBody = await calendlyResponse.json().catch(() => ({})) + const errorMessage = + (errorBody as Record).message || + (errorBody as Record).title || + 'Unknown Calendly API error' + logger.error( + `[${ctx.requestId}] Failed to create webhook in Calendly for webhook ${ctx.webhook.id}. Status: ${calendlyResponse.status}`, + { response: errorBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Calendly' + if (calendlyResponse.status === 401) { + userFriendlyMessage = + 'Calendly authentication failed. Please verify your Personal Access Token is correct.' + } else if (calendlyResponse.status === 403) { + userFriendlyMessage = + 'Calendly access denied. Please ensure you have appropriate permissions and a paid Calendly subscription.' + } else if (calendlyResponse.status === 404) { + userFriendlyMessage = + 'Calendly organization not found. Please verify the Organization URI is correct.' + } else if (errorMessage && errorMessage !== 'Unknown Calendly API error') { + userFriendlyMessage = `Calendly error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = (await calendlyResponse.json()) as Record + const resource = responseBody.resource as Record | undefined + const webhookUri = resource?.uri as string | undefined + + if (!webhookUri) { + logger.error( + `[${ctx.requestId}] Calendly webhook created but no webhook URI returned for webhook ${ctx.webhook.id}`, + { response: responseBody } + ) + throw new Error('Calendly webhook creation succeeded but no webhook URI was returned') + } + + const webhookId = webhookUri.split('/').pop() + + if (!webhookId) { + logger.error( + `[${ctx.requestId}] Could not extract webhook ID from Calendly URI: ${webhookUri}`, + { + response: responseBody, + } + ) + throw new Error('Failed to extract webhook ID from Calendly response') + } + + logger.info( + `[${ctx.requestId}] Successfully created webhook in Calendly for webhook ${ctx.webhook.id}.`, + { + calendlyWebhookUri: webhookUri, + calendlyWebhookId: webhookId, + } + ) + return { providerConfigUpdates: { externalId: webhookId } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${ctx.requestId}] Exception during Calendly webhook creation for webhook ${ctx.webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${ctx.requestId}] Missing apiKey for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${ctx.requestId}] Missing externalId for Calendly webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + const calendlyApiUrl = `https://api.calendly.com/webhook_subscriptions/${externalId}` + + const calendlyResponse = await fetch(calendlyApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!calendlyResponse.ok && calendlyResponse.status !== 404) { + const responseBody = await calendlyResponse.json().catch(() => ({})) + logger.warn( + `[${ctx.requestId}] Failed to delete Calendly webhook (non-fatal): ${calendlyResponse.status}`, + { response: responseBody } + ) + } else { + logger.info( + `[${ctx.requestId}] Successfully deleted Calendly webhook subscription ${externalId}` + ) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Calendly webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/circleback.ts b/apps/sim/lib/webhooks/providers/circleback.ts new file mode 100644 index 00000000000..1beb8a6814f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/circleback.ts @@ -0,0 +1,67 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Circleback') + +function validateCirclebackSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Circleback signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Circleback signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating Circleback signature:', error) + return false + } +} + +export const circlebackHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + id: b.id, + name: b.name, + createdAt: b.createdAt, + duration: b.duration, + url: b.url, + recordingUrl: b.recordingUrl, + tags: b.tags || [], + icalUid: b.icalUid, + attendees: b.attendees || [], + notes: b.notes || '', + actionItems: b.actionItems || [], + transcript: b.transcript || [], + insights: b.insights || {}, + meeting: b, + }, + } + }, + + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'x-signature', + validateFn: validateCirclebackSignature, + providerLabel: 'Circleback', + }), +} diff --git a/apps/sim/lib/webhooks/providers/confluence.ts b/apps/sim/lib/webhooks/providers/confluence.ts new file mode 100644 index 00000000000..79fda7c0529 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/confluence.ts @@ -0,0 +1,92 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { validateJiraSignature } from '@/lib/webhooks/providers/jira' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Confluence') + +export const confluenceHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Hub-Signature', + validateFn: validateJiraSignature, + providerLabel: 'Confluence', + }), + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { + extractPageData, + extractCommentData, + extractBlogData, + extractAttachmentData, + extractSpaceData, + extractLabelData, + } = await import('@/triggers/confluence/utils') + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId?.startsWith('confluence_comment_')) { + return { input: extractCommentData(body) } + } + if (triggerId?.startsWith('confluence_blog_')) { + return { input: extractBlogData(body) } + } + if (triggerId?.startsWith('confluence_attachment_')) { + return { input: extractAttachmentData(body) } + } + if (triggerId?.startsWith('confluence_space_')) { + return { input: extractSpaceData(body) } + } + if (triggerId?.startsWith('confluence_label_')) { + return { input: extractLabelData(body) } + } + if (triggerId === 'confluence_webhook') { + const b = body as Record + return { + input: { + timestamp: b.timestamp, + userAccountId: b.userAccountId, + accountType: b.accountType, + page: b.page || null, + comment: b.comment || null, + blog: b.blog || (b as Record).blogpost || null, + attachment: b.attachment || null, + space: b.space || null, + label: b.label || null, + content: b.content || null, + }, + } + } + return { input: extractPageData(body) } + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId) { + const { isConfluencePayloadMatch } = await import('@/triggers/confluence/utils') + if (!isConfluencePayloadMatch(triggerId, obj)) { + logger.debug( + `[${requestId}] Confluence payload mismatch for trigger ${triggerId}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + bodyKeys: Object.keys(obj), + } + ) + return NextResponse.json({ + message: 'Payload does not match trigger configuration. Ignoring.', + }) + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/fathom.ts b/apps/sim/lib/webhooks/providers/fathom.ts new file mode 100644 index 00000000000..c705d00353f --- /dev/null +++ b/apps/sim/lib/webhooks/providers/fathom.ts @@ -0,0 +1,173 @@ +import { createLogger } from '@sim/logger' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Fathom') + +export const fathomHandler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const triggeredFor = providerConfig.triggeredFor as string | undefined + const includeSummary = providerConfig.includeSummary as unknown + const includeTranscript = providerConfig.includeTranscript as unknown + const includeActionItems = providerConfig.includeActionItems as unknown + const includeCrmMatches = providerConfig.includeCrmMatches as unknown + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Fathom webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Fathom API Key is required. Please provide your API key in the trigger configuration.' + ) + } + + const notificationUrl = getNotificationUrl(webhook) + + const triggeredForValue = triggeredFor || 'my_recordings' + + const toBool = (val: unknown, fallback: boolean): boolean => { + if (val === undefined) return fallback + return val === true || val === 'true' + } + + const requestBody: Record = { + destination_url: notificationUrl, + triggered_for: [triggeredForValue], + include_summary: toBool(includeSummary, true), + include_transcript: toBool(includeTranscript, false), + include_action_items: toBool(includeActionItems, false), + include_crm_matches: toBool(includeCrmMatches, false), + } + + logger.info(`[${requestId}] Creating Fathom webhook`, { + triggerId, + triggeredFor: triggeredForValue, + webhookId: webhook.id, + }) + + const fathomResponse = await fetch('https://api.fathom.ai/external/v1/webhooks', { + method: 'POST', + headers: { + 'X-Api-Key': apiKey, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await fathomResponse.json().catch(() => ({}))) as Record< + string, + unknown + > + + if (!fathomResponse.ok) { + const errorMessage = + (responseBody.message as string) || + (responseBody.error as string) || + 'Unknown Fathom API error' + logger.error( + `[${requestId}] Failed to create webhook in Fathom for webhook ${webhook.id}. Status: ${fathomResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Fathom' + if (fathomResponse.status === 401) { + userFriendlyMessage = 'Invalid Fathom API Key. Please verify your key is correct.' + } else if (fathomResponse.status === 400) { + userFriendlyMessage = `Fathom error: ${errorMessage}` + } else if (errorMessage && errorMessage !== 'Unknown Fathom API error') { + userFriendlyMessage = `Fathom error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + if (!responseBody.id) { + logger.error( + `[${requestId}] Fathom webhook creation returned success but no webhook ID for ${webhook.id}.` + ) + throw new Error('Fathom webhook created but no ID returned. Please try again.') + } + + logger.info( + `[${requestId}] Successfully created webhook in Fathom for webhook ${webhook.id}.`, + { + fathomWebhookId: responseBody.id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody.id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Fathom webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${requestId}] Missing apiKey for Fathom webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Fathom webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const idValidation = validateAlphanumericId(externalId, 'Fathom webhook ID', 100) + if (!idValidation.isValid) { + logger.warn( + `[${requestId}] Invalid externalId format for Fathom webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const fathomApiUrl = `https://api.fathom.ai/external/v1/webhooks/${externalId}` + + const fathomResponse = await fetch(fathomApiUrl, { + method: 'DELETE', + headers: { + 'X-Api-Key': apiKey, + 'Content-Type': 'application/json', + }, + }) + + if (!fathomResponse.ok && fathomResponse.status !== 404) { + logger.warn( + `[${requestId}] Failed to delete Fathom webhook (non-fatal): ${fathomResponse.status}` + ) + } else { + logger.info(`[${requestId}] Successfully deleted Fathom webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Fathom webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/fireflies.ts b/apps/sim/lib/webhooks/providers/fireflies.ts new file mode 100644 index 00000000000..f3245d5cd3c --- /dev/null +++ b/apps/sim/lib/webhooks/providers/fireflies.ts @@ -0,0 +1,63 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Fireflies') + +function validateFirefliesSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Fireflies signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + if (!signature.startsWith('sha256=')) { + logger.warn('Fireflies signature has invalid format (expected sha256=)', { + signaturePrefix: signature.substring(0, 10), + }) + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Fireflies signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Fireflies signature:', error) + return false + } +} + +export const firefliesHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + meetingId: (b.meetingId || '') as string, + eventType: (b.eventType || 'Transcription completed') as string, + clientReferenceId: (b.clientReferenceId || '') as string, + }, + } + }, + + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'x-hub-signature', + validateFn: validateFirefliesSignature, + providerLabel: 'Fireflies', + }), +} diff --git a/apps/sim/lib/webhooks/providers/generic.ts b/apps/sim/lib/webhooks/providers/generic.ts new file mode 100644 index 00000000000..712cd09d12e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/generic.ts @@ -0,0 +1,145 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + EventFilterContext, + FormatInputContext, + FormatInputResult, + ProcessFilesContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Generic') + +export const genericHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }: AuthContext) { + if (providerConfig.requireAuth) { + const configToken = providerConfig.token as string | undefined + if (!configToken) { + return new NextResponse('Unauthorized - Authentication required but no token configured', { + status: 401, + }) + } + + const secretHeaderName = providerConfig.secretHeaderName as string | undefined + if (!verifyTokenAuth(request, configToken, secretHeaderName)) { + return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) + } + } + + const allowedIps = providerConfig.allowedIps + if (allowedIps && Array.isArray(allowedIps) && allowedIps.length > 0) { + const clientIp = + request.headers.get('x-forwarded-for')?.split(',')[0].trim() || + request.headers.get('x-real-ip') || + 'unknown' + + if (clientIp === 'unknown' || !allowedIps.includes(clientIp)) { + logger.warn(`[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}`) + return new NextResponse('Forbidden - IP not allowed', { + status: 403, + }) + } + } + + return null + }, + + enrichHeaders({ body, providerConfig }: EventFilterContext, headers: Record) { + const idempotencyField = providerConfig.idempotencyField as string | undefined + if (idempotencyField && body) { + const value = idempotencyField + .split('.') + .reduce( + (acc: unknown, key: string) => + acc && typeof acc === 'object' ? (acc as Record)[key] : undefined, + body + ) + if (value !== undefined && value !== null && typeof value !== 'object') { + headers['x-sim-idempotency-key'] = String(value) + } + } + }, + + formatSuccessResponse(providerConfig: Record) { + if (providerConfig.responseMode === 'custom') { + const rawCode = Number(providerConfig.responseStatusCode) || 200 + const statusCode = rawCode >= 100 && rawCode <= 599 ? rawCode : 200 + const responseBody = (providerConfig.responseBody as string | undefined)?.trim() + + if (!responseBody) { + return new NextResponse(null, { status: statusCode }) + } + + try { + const parsed = JSON.parse(responseBody) + return NextResponse.json(parsed, { status: statusCode }) + } catch { + return new NextResponse(responseBody, { + status: statusCode, + headers: { 'Content-Type': 'text/plain' }, + }) + } + } + + return null + }, + + async formatInput({ body }: FormatInputContext): Promise { + return { input: body } + }, + + async processInputFiles({ + input, + blocks, + blockId, + workspaceId, + workflowId, + executionId, + requestId, + userId, + }: ProcessFilesContext) { + const triggerBlock = blocks[blockId] as Record | undefined + const subBlocks = triggerBlock?.subBlocks as Record | undefined + const inputFormatBlock = subBlocks?.inputFormat as Record | undefined + + if (inputFormatBlock?.value) { + const inputFormat = inputFormatBlock.value as Array<{ + name: string + type: 'string' | 'number' | 'boolean' | 'object' | 'array' | 'file[]' + }> + + const fileFields = inputFormat.filter((field) => field.type === 'file[]') + + if (fileFields.length > 0) { + const { processExecutionFiles } = await import('@/lib/execution/files') + const executionContext = { + workspaceId, + workflowId, + executionId, + } + + for (const fileField of fileFields) { + const fieldValue = input[fileField.name] + + if (fieldValue && typeof fieldValue === 'object') { + const uploadedFiles = await processExecutionFiles( + fieldValue, + executionContext, + requestId, + userId + ) + + if (uploadedFiles.length > 0) { + input[fileField.name] = uploadedFiles + logger.info( + `[${requestId}] Successfully processed ${uploadedFiles.length} file(s) for field: ${fileField.name}` + ) + } + } + } + } + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/github.ts b/apps/sim/lib/webhooks/providers/github.ts new file mode 100644 index 00000000000..a0fd90f2e6d --- /dev/null +++ b/apps/sim/lib/webhooks/providers/github.ts @@ -0,0 +1,124 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:GitHub') + +function validateGitHubSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('GitHub signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + let algorithm: 'sha256' | 'sha1' + let providedSignature: string + if (signature.startsWith('sha256=')) { + algorithm = 'sha256' + providedSignature = signature.substring(7) + } else if (signature.startsWith('sha1=')) { + algorithm = 'sha1' + providedSignature = signature.substring(5) + } else { + logger.warn('GitHub signature has invalid format', { + signature: `${signature.substring(0, 10)}...`, + }) + return false + } + const computedHash = crypto.createHmac(algorithm, secret).update(body, 'utf8').digest('hex') + logger.debug('GitHub signature comparison', { + algorithm, + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating GitHub signature:', error) + return false + } +} + +export const githubHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) { + return null + } + + const signature = + request.headers.get('X-Hub-Signature-256') || request.headers.get('X-Hub-Signature') + if (!signature) { + logger.warn(`[${requestId}] GitHub webhook missing signature header`) + return new NextResponse('Unauthorized - Missing GitHub signature', { status: 401 }) + } + + if (!validateGitHubSignature(secret, signature, rawBody)) { + logger.warn(`[${requestId}] GitHub signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + usingSha256: !!request.headers.get('X-Hub-Signature-256'), + }) + return new NextResponse('Unauthorized - Invalid GitHub signature', { status: 401 }) + } + + return null + }, + + async formatInput({ body, headers }: FormatInputContext): Promise { + const b = body as Record + const eventType = headers['x-github-event'] || 'unknown' + const ref = (b?.ref as string) || '' + const branch = ref.replace('refs/heads/', '') + return { + input: { ...b, event_type: eventType, action: (b?.action || '') as string, branch }, + } + }, + + async matchEvent({ + webhook, + workflow, + body, + request, + requestId, + providerConfig, + }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'github_webhook') { + const eventType = request.headers.get('x-github-event') + const action = obj.action as string | undefined + + const { isGitHubEventMatch } = await import('@/triggers/github/utils') + if (!isGitHubEventMatch(triggerId, eventType || '', action, obj)) { + logger.debug( + `[${requestId}] GitHub event mismatch for trigger ${triggerId}. Event: ${eventType}, Action: ${action}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: eventType, + receivedAction: action, + } + ) + return false + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/gmail.ts b/apps/sim/lib/webhooks/providers/gmail.ts new file mode 100644 index 00000000000..650ebfb0980 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gmail.ts @@ -0,0 +1,117 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { + FormatInputContext, + FormatInputResult, + PollingConfigContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Gmail') + +export const gmailHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'email' in b) { + return { input: { email: b.email, timestamp: b.timestamp } } + } + return { input: b } + }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId as string | undefined + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) + return false + } + + const resolvedGmail = await resolveOAuthAccountId(credentialId) + if (!resolvedGmail) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedGmail.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + + const accessToken = await refreshAccessTokenIfNeeded( + resolvedGmail.accountId, + effectiveUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` + ) + return false + } + + const maxEmailsPerPoll = + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : (providerConfig.maxEmailsPerPoll as number) || 25 + + const pollingInterval = + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : (providerConfig.pollingInterval as number) || 5 + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll, + pollingInterval, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + labelIds: providerConfig.labelIds || ['INBOX'], + labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: + (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure Gmail polling`, { + webhookId: webhookData.id, + error: err.message, + stack: err.stack, + }) + return false + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/google-forms.ts b/apps/sim/lib/webhooks/providers/google-forms.ts new file mode 100644 index 00000000000..67e7fd8c997 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/google-forms.ts @@ -0,0 +1,60 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import type { + AuthContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { verifyTokenAuth } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:GoogleForms') + +export const googleFormsHandler: WebhookProviderHandler = { + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const normalizeAnswers = (src: unknown): Record => { + if (!src || typeof src !== 'object') return {} + const out: Record = {} + for (const [k, v] of Object.entries(src as Record)) { + if (Array.isArray(v)) { + out[k] = v.length === 1 ? v[0] : v + } else { + out[k] = v + } + } + return out + } + const responseId = (b?.responseId || b?.id || '') as string + const createTime = (b?.createTime || b?.timestamp || new Date().toISOString()) as string + const lastSubmittedTime = (b?.lastSubmittedTime || createTime) as string + const formId = (b?.formId || providerConfig.formId || '') as string + const includeRaw = providerConfig.includeRawPayload !== false + return { + input: { + responseId, + createTime, + lastSubmittedTime, + formId, + answers: normalizeAnswers(b?.answers), + ...(includeRaw ? { raw: b?.raw ?? b } : {}), + }, + } + }, + + verifyAuth({ request, requestId, providerConfig }: AuthContext) { + const expectedToken = providerConfig.token as string | undefined + if (!expectedToken) { + return null + } + + const secretHeaderName = providerConfig.secretHeaderName as string | undefined + if (!verifyTokenAuth(request, expectedToken, secretHeaderName)) { + logger.warn(`[${requestId}] Google Forms webhook authentication failed`) + return new NextResponse('Unauthorized - Invalid secret', { status: 401 }) + } + + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/grain.ts b/apps/sim/lib/webhooks/providers/grain.ts new file mode 100644 index 00000000000..02bb0122076 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/grain.ts @@ -0,0 +1,251 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + EventFilterContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { skipByEventTypes } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Grain') + +export const grainHandler: WebhookProviderHandler = { + handleReachabilityTest(body: unknown, requestId: string) { + const obj = body as Record | null + const isVerificationRequest = !obj || Object.keys(obj).length === 0 || !obj.type + if (isVerificationRequest) { + logger.info( + `[${requestId}] Grain reachability test detected - returning 200 for webhook verification` + ) + return NextResponse.json({ + status: 'ok', + message: 'Webhook endpoint verified', + }) + } + return null + }, + + shouldSkipEvent(ctx: EventFilterContext) { + return skipByEventTypes(ctx, 'Grain', logger) + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { input: { type: b.type, user_id: b.user_id, data: b.data || {} } } + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const data = obj.data as Record | undefined + if (obj.type && data?.id) { + return `${obj.type}:${data.id}` + } + return null + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const viewId = providerConfig.viewId as string | undefined + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Grain webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Grain API Key is required. Please provide your Grain Personal Access Token in the trigger configuration.' + ) + } + + if (!viewId) { + logger.warn(`[${requestId}] Missing viewId for Grain webhook creation.`, { + webhookId: webhook.id, + triggerId, + }) + throw new Error( + 'Grain view ID is required. Please provide the Grain view ID from GET /_/public-api/views in the trigger configuration.' + ) + } + + const actionMap: Record> = { + grain_item_added: ['added'], + grain_item_updated: ['updated'], + grain_recording_created: ['added'], + grain_recording_updated: ['updated'], + grain_highlight_created: ['added'], + grain_highlight_updated: ['updated'], + grain_story_created: ['added'], + } + + const eventTypeMap: Record = { + grain_webhook: [], + grain_item_added: [], + grain_item_updated: [], + grain_recording_created: ['recording_added'], + grain_recording_updated: ['recording_updated'], + grain_highlight_created: ['highlight_added'], + grain_highlight_updated: ['highlight_updated'], + grain_story_created: ['story_added'], + } + + const actions = actionMap[triggerId ?? ''] ?? [] + const eventTypes = eventTypeMap[triggerId ?? ''] ?? [] + + if (!triggerId || (!(triggerId in actionMap) && triggerId !== 'grain_webhook')) { + logger.warn( + `[${requestId}] Unknown triggerId for Grain: ${triggerId}, defaulting to all actions`, + { + webhookId: webhook.id, + } + ) + } + + logger.info(`[${requestId}] Creating Grain webhook`, { + triggerId, + viewId, + actions, + eventTypes, + webhookId: webhook.id, + }) + + const notificationUrl = getNotificationUrl(webhook) + + const grainApiUrl = 'https://api.grain.com/_/public-api/hooks' + + const requestBody: Record = { + version: 2, + hook_url: notificationUrl, + view_id: viewId, + } + if (actions.length > 0) { + requestBody.actions = actions + } + + const grainResponse = await fetch(grainApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await grainResponse.json()) as Record + + if (!grainResponse.ok || responseBody.error || responseBody.errors) { + const errors = responseBody.errors as Record | undefined + const error = responseBody.error as Record | string | undefined + const errorMessage = + errors?.detail || + (typeof error === 'object' ? error?.message : undefined) || + (typeof error === 'string' ? error : undefined) || + (responseBody.message as string) || + 'Unknown Grain API error' + logger.error( + `[${requestId}] Failed to create webhook in Grain for webhook ${webhook.id}. Status: ${grainResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Grain' + if (grainResponse.status === 401) { + userFriendlyMessage = + 'Invalid Grain API Key. Please verify your Personal Access Token is correct.' + } else if (grainResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Grain API Key has appropriate permissions.' + } else if (errorMessage && errorMessage !== 'Unknown Grain API error') { + userFriendlyMessage = `Grain error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const grainWebhookId = responseBody.id as string | undefined + + if (!grainWebhookId) { + logger.error( + `[${requestId}] Grain webhook creation response missing id for webhook ${webhook.id}.`, + { + response: responseBody, + } + ) + throw new Error( + 'Grain webhook created but no webhook ID was returned in the response. Cannot track subscription.' + ) + } + + logger.info( + `[${requestId}] Successfully created webhook in Grain for webhook ${webhook.id}.`, + { + grainWebhookId, + eventTypes, + } + ) + + return { providerConfigUpdates: { externalId: grainWebhookId, eventTypes } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Grain webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${requestId}] Missing apiKey for Grain webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Grain webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const grainApiUrl = `https://api.grain.com/_/public-api/hooks/${externalId}` + + const grainResponse = await fetch(grainApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + }) + + if (!grainResponse.ok && grainResponse.status !== 404) { + const responseBody = await grainResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Grain webhook (non-fatal): ${grainResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Grain webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Grain webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/hubspot.ts b/apps/sim/lib/webhooks/providers/hubspot.ts new file mode 100644 index 00000000000..2591ee40175 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/hubspot.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:HubSpot') + +export const hubspotHandler: WebhookProviderHandler = { + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + + if (triggerId?.startsWith('hubspot_')) { + const events = Array.isArray(body) ? body : [body] + const firstEvent = events[0] as Record | undefined + const subscriptionType = firstEvent?.subscriptionType as string | undefined + + const { isHubSpotContactEventMatch } = await import('@/triggers/hubspot/utils') + if (!isHubSpotContactEventMatch(triggerId, subscriptionType || '')) { + logger.debug( + `[${requestId}] HubSpot event mismatch for trigger ${triggerId}. Event: ${subscriptionType}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: subscriptionType, + } + ) + return false + } + + logger.info( + `[${requestId}] HubSpot event match confirmed for trigger ${triggerId}. Event: ${subscriptionType}`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: subscriptionType, + } + ) + } + + return true + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const events = Array.isArray(b) ? b : [b] + const event = events[0] as Record | undefined + if (!event) { + logger.warn('HubSpot webhook received with empty payload') + return { input: null } + } + logger.info('Formatting HubSpot webhook input', { + subscriptionType: event.subscriptionType, + objectId: event.objectId, + portalId: event.portalId, + }) + return { + input: { payload: body, provider: 'hubspot', providerConfig: webhook.providerConfig }, + } + }, + + extractIdempotencyId(body: unknown) { + if (Array.isArray(body) && body.length > 0) { + const first = body[0] as Record + if (first?.eventId) { + return String(first.eventId) + } + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/imap.ts b/apps/sim/lib/webhooks/providers/imap.ts new file mode 100644 index 00000000000..aff02b2a341 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/imap.ts @@ -0,0 +1,84 @@ +import { db } from '@sim/db' +import { webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { + FormatInputContext, + FormatInputResult, + PollingConfigContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Imap') + +export const imapHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'email' in b) { + return { + input: { + messageId: b.messageId, + subject: b.subject, + from: b.from, + to: b.to, + cc: b.cc, + date: b.date, + bodyText: b.bodyText, + bodyHtml: b.bodyHtml, + mailbox: b.mailbox, + hasAttachments: b.hasAttachments, + attachments: b.attachments, + email: b.email, + timestamp: b.timestamp, + }, + } + } + return { input: b } + }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const now = new Date() + + if (!providerConfig.host || !providerConfig.username || !providerConfig.password) { + logger.error( + `[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}` + ) + return false + } + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + port: providerConfig.port || '993', + secure: providerConfig.secure !== false, + mailbox: providerConfig.mailbox || 'INBOX', + searchCriteria: providerConfig.searchCriteria || 'UNSEEN', + markAsRead: providerConfig.markAsRead || false, + includeAttachments: providerConfig.includeAttachments !== false, + lastCheckedTimestamp: now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure IMAP polling`, { + webhookId: webhookData.id, + error: err.message, + }) + return false + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/index.ts b/apps/sim/lib/webhooks/providers/index.ts new file mode 100644 index 00000000000..dd43ec6c223 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/index.ts @@ -0,0 +1,24 @@ +export { getProviderHandler } from '@/lib/webhooks/providers/registry' +export type { + AuthContext, + EventFilterContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + ProcessFilesContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +import { getProviderHandler } from '@/lib/webhooks/providers/registry' + +/** + * Extract a provider-specific unique identifier from the webhook body for idempotency. + */ +export function extractProviderIdentifierFromBody(provider: string, body: unknown): string | null { + if (!body || typeof body !== 'object') { + return null + } + + const handler = getProviderHandler(provider) + return handler.extractIdempotencyId?.(body) ?? null +} diff --git a/apps/sim/lib/webhooks/providers/jira.ts b/apps/sim/lib/webhooks/providers/jira.ts new file mode 100644 index 00000000000..1520238d11e --- /dev/null +++ b/apps/sim/lib/webhooks/providers/jira.ts @@ -0,0 +1,104 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Jira') + +export function validateJiraSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Jira signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + if (!signature.startsWith('sha256=')) { + logger.warn('Jira signature has invalid format (expected sha256=)', { + signaturePrefix: signature.substring(0, 10), + }) + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Jira signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedSignature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedSignature.length, + match: computedHash === providedSignature, + }) + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Jira signature:', error) + return false + } +} + +export const jiraHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Hub-Signature', + validateFn: validateJiraSignature, + providerLabel: 'Jira', + }), + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const { extractIssueData, extractCommentData, extractWorklogData } = await import( + '@/triggers/jira/utils' + ) + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId === 'jira_issue_commented') { + return { input: extractCommentData(body) } + } + if (triggerId === 'jira_worklog_created') { + return { input: extractWorklogData(body) } + } + return { input: extractIssueData(body) } + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'jira_webhook') { + const webhookEvent = obj.webhookEvent as string | undefined + const issueEventTypeName = obj.issue_event_type_name as string | undefined + + const { isJiraEventMatch } = await import('@/triggers/jira/utils') + if (!isJiraEventMatch(triggerId, webhookEvent || '', issueEventTypeName)) { + logger.debug( + `[${requestId}] Jira event mismatch for trigger ${triggerId}. Event: ${webhookEvent}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: webhookEvent, + } + ) + return false + } + } + + return true + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const issue = obj.issue as Record | undefined + const project = obj.project as Record | undefined + if (obj.webhookEvent && (issue?.id || project?.id)) { + return `${obj.webhookEvent}:${issue?.id || project?.id}` + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/lemlist.ts b/apps/sim/lib/webhooks/providers/lemlist.ts new file mode 100644 index 00000000000..2127512f9d3 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/lemlist.ts @@ -0,0 +1,218 @@ +import { createLogger } from '@sim/logger' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Lemlist') + +export const lemlistHandler: WebhookProviderHandler = { + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + const campaignId = providerConfig.campaignId as string | undefined + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Lemlist webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Lemlist API Key is required. Please provide your Lemlist API Key in the trigger configuration.' + ) + } + + const eventTypeMap: Record = { + lemlist_email_replied: 'emailsReplied', + lemlist_linkedin_replied: 'linkedinReplied', + lemlist_interested: 'interested', + lemlist_not_interested: 'notInterested', + lemlist_email_opened: 'emailsOpened', + lemlist_email_clicked: 'emailsClicked', + lemlist_email_bounced: 'emailsBounced', + lemlist_email_sent: 'emailsSent', + lemlist_webhook: undefined, + } + + const eventType = eventTypeMap[triggerId ?? ''] + const notificationUrl = getNotificationUrl(webhook) + const authString = Buffer.from(`:${apiKey}`).toString('base64') + + logger.info(`[${requestId}] Creating Lemlist webhook`, { + triggerId, + eventType, + hasCampaignId: !!campaignId, + webhookId: webhook.id, + }) + + const lemlistApiUrl = 'https://api.lemlist.com/api/hooks' + + const requestBody: Record = { + targetUrl: notificationUrl, + } + + if (eventType) { + requestBody.type = eventType + } + + if (campaignId) { + requestBody.campaignId = campaignId + } + + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'POST', + headers: { + Authorization: `Basic ${authString}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = (await lemlistResponse.json()) as Record + + if (!lemlistResponse.ok || responseBody.error) { + const errorMessage = + (responseBody.message as string) || + (responseBody.error as string) || + 'Unknown Lemlist API error' + logger.error( + `[${requestId}] Failed to create webhook in Lemlist for webhook ${webhook.id}. Status: ${lemlistResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Lemlist' + if (lemlistResponse.status === 401) { + userFriendlyMessage = 'Invalid Lemlist API Key. Please verify your API Key is correct.' + } else if (lemlistResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure your Lemlist API Key has appropriate permissions.' + } else if (errorMessage && errorMessage !== 'Unknown Lemlist API error') { + userFriendlyMessage = `Lemlist error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${requestId}] Successfully created webhook in Lemlist for webhook ${webhook.id}.`, + { + lemlistWebhookId: responseBody._id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody._id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Lemlist webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey) { + logger.warn( + `[${requestId}] Missing apiKey for Lemlist webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const authString = Buffer.from(`:${apiKey}`).toString('base64') + + const deleteById = async (id: string) => { + const validation = validateAlphanumericId(id, 'Lemlist hook ID', 50) + if (!validation.isValid) { + logger.warn(`[${requestId}] Invalid Lemlist hook ID format, skipping deletion`, { + id: id.substring(0, 30), + }) + return + } + + const lemlistApiUrl = `https://api.lemlist.com/api/hooks/${id}` + const lemlistResponse = await fetch(lemlistApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Basic ${authString}`, + }, + }) + + if (!lemlistResponse.ok && lemlistResponse.status !== 404) { + const responseBody = await lemlistResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Lemlist webhook (non-fatal): ${lemlistResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Lemlist webhook ${id}`) + } + } + + if (externalId) { + await deleteById(externalId) + return + } + + const notificationUrl = getNotificationUrl(webhook) + const listResponse = await fetch('https://api.lemlist.com/api/hooks', { + method: 'GET', + headers: { + Authorization: `Basic ${authString}`, + }, + }) + + if (!listResponse.ok) { + logger.warn(`[${requestId}] Failed to list Lemlist webhooks for cleanup ${webhook.id}`, { + status: listResponse.status, + }) + return + } + + const listBody = (await listResponse.json().catch(() => null)) as + | Record + | Array> + | null + const hooks: Array> = Array.isArray(listBody) + ? listBody + : ((listBody as Record)?.hooks as Array>) || + ((listBody as Record)?.data as Array>) || + [] + const matches = hooks.filter((hook) => { + const targetUrl = hook?.targetUrl || hook?.target_url || hook?.url + return typeof targetUrl === 'string' && targetUrl === notificationUrl + }) + + if (matches.length === 0) { + logger.info(`[${requestId}] Lemlist webhook not found for cleanup ${webhook.id}`, { + notificationUrl, + }) + return + } + + for (const hook of matches) { + const hookId = (hook?._id || hook?.id) as string | undefined + if (typeof hookId === 'string' && hookId.length > 0) { + await deleteById(hookId) + } + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Lemlist webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts new file mode 100644 index 00000000000..9372b8d6009 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/linear.ts @@ -0,0 +1,71 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Linear') + +function validateLinearSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Linear signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + logger.debug('Linear signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: signature.length, + match: computedHash === signature, + }) + return safeCompare(computedHash, signature) + } catch (error) { + logger.error('Error validating Linear signature:', error) + return false + } +} + +export const linearHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'Linear-Signature', + validateFn: validateLinearSignature, + providerLabel: 'Linear', + }), + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + action: b.action || '', + type: b.type || '', + webhookId: b.webhookId || '', + webhookTimestamp: b.webhookTimestamp || 0, + organizationId: b.organizationId || '', + createdAt: b.createdAt || '', + actor: b.actor || null, + data: b.data || null, + updatedFrom: b.updatedFrom || null, + }, + } + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + const data = obj.data as Record | undefined + if (obj.action && data?.id) { + return `${obj.action}:${data.id}` + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/microsoft-teams.ts b/apps/sim/lib/webhooks/providers/microsoft-teams.ts new file mode 100644 index 00000000000..8270eb93e01 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts @@ -0,0 +1,787 @@ +import crypto from 'crypto' +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import { + type SecureFetchResponse, + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import { sanitizeUrlForLog } from '@/lib/core/utils/logging' +import { + getCredentialOwner, + getNotificationUrl, + getProviderConfig, +} from '@/lib/webhooks/providers/subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + EventFilterContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:MicrosoftTeams') + +function validateMicrosoftTeamsSignature( + hmacSecret: string, + signature: string, + body: string +): boolean { + try { + if (!hmacSecret || !signature || !body) { + return false + } + if (!signature.startsWith('HMAC ')) { + return false + } + const providedSignature = signature.substring(5) + const secretBytes = Buffer.from(hmacSecret, 'base64') + const bodyBytes = Buffer.from(body, 'utf8') + const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Microsoft Teams signature:', error) + return false + } +} + +function parseFirstNotification( + body: unknown +): { subscriptionId: string; messageId: string } | null { + const obj = body as Record + const value = obj.value as unknown[] | undefined + if (!Array.isArray(value) || value.length === 0) { + return null + } + + const notification = value[0] as Record + const subscriptionId = notification.subscriptionId as string | undefined + const resourceData = notification.resourceData as Record | undefined + const messageId = resourceData?.id as string | undefined + + if (subscriptionId && messageId) { + return { subscriptionId, messageId } + } + return null +} + +async function fetchWithDNSPinning( + url: string, + accessToken: string, + requestId: string +): Promise { + try { + const urlValidation = await validateUrlWithDNS(url, 'contentUrl') + if (!urlValidation.isValid) { + logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { url }) + return null + } + const headers: Record = {} + if (accessToken) { + headers.Authorization = `Bearer ${accessToken}` + } + const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { headers }) + return response + } catch (error) { + logger.error(`[${requestId}] Error fetching URL with DNS pinning`, { + error: error instanceof Error ? error.message : String(error), + url: sanitizeUrlForLog(url), + }) + return null + } +} + +/** + * Format Microsoft Teams Graph change notification + */ +async function formatTeamsGraphNotification( + body: Record, + foundWebhook: Record, + foundWorkflow: { id: string; userId: string }, + request: { headers: Map } +): Promise { + const notification = (body.value as unknown[])?.[0] as Record | undefined + if (!notification) { + logger.warn('Received empty Teams notification body') + return null + } + const changeType = (notification.changeType as string) || 'created' + const resource = (notification.resource as string) || '' + const subscriptionId = (notification.subscriptionId as string) || '' + + let chatId: string | null = null + let messageId: string | null = null + + const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) + if (fullMatch) { + chatId = fullMatch[1] + messageId = fullMatch[2] + } + + if (!chatId || !messageId) { + const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (quotedMatch) { + chatId = quotedMatch[1] + messageId = quotedMatch[2] + } + } + + if (!chatId || !messageId) { + const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/) + const rdId = ((body?.value as unknown[])?.[0] as Record)?.resourceData as + | Record + | undefined + const rdIdValue = rdId?.id as string | undefined + if (collectionMatch && rdIdValue) { + chatId = collectionMatch[1] + messageId = rdIdValue + } + } + + if ( + (!chatId || !messageId) && + ((body?.value as unknown[])?.[0] as Record)?.resourceData + ) { + const resourceData = ((body.value as unknown[])[0] as Record) + .resourceData as Record + const odataId = resourceData['@odata.id'] + if (typeof odataId === 'string') { + const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) + if (odataMatch) { + chatId = odataMatch[1] + messageId = odataMatch[2] + } + } + } + + if (!chatId || !messageId) { + logger.warn('Could not resolve chatId/messageId from Teams notification', { + resource, + hasResourceDataId: Boolean( + ((body?.value as unknown[])?.[0] as Record)?.resourceData + ), + valueLength: Array.isArray(body?.value) ? (body.value as unknown[]).length : 0, + keys: Object.keys(body || {}), + }) + return { + from: null, + message: { raw: body }, + activity: body, + conversation: null, + } + } + const resolvedChatId = chatId as string + const resolvedMessageId = messageId as string + const providerConfig = (foundWebhook?.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId + const includeAttachments = providerConfig.includeAttachments !== false + + let message: Record | null = null + const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = + [] + let accessToken: string | null = null + + if (!credentialId) { + logger.error('Missing credentialId for Teams chat subscription', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + webhookId: foundWebhook?.id, + blockId: foundWebhook?.blockId, + providerConfig, + }) + } else { + try { + const resolved = await resolveOAuthAccountId(credentialId as string) + if (!resolved) { + logger.error('Teams credential could not be resolved', { credentialId }) + } else { + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) + } else { + const effectiveUserId = rows[0].userId + accessToken = await refreshAccessTokenIfNeeded( + resolved.accountId, + effectiveUserId, + 'teams-graph-notification' + ) + } + } + + if (accessToken) { + const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` + const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) + if (res.ok) { + message = (await res.json()) as Record + + if (includeAttachments && (message?.attachments as unknown[] | undefined)?.length) { + const attachments = Array.isArray(message?.attachments) + ? (message.attachments as Record[]) + : [] + for (const att of attachments) { + try { + const contentUrl = + typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined + const contentTypeHint = + typeof att?.contentType === 'string' ? (att.contentType as string) : undefined + let attachmentName = (att?.name as string) || 'teams-attachment' + + if (!contentUrl) continue + + let buffer: Buffer | null = null + let mimeType = 'application/octet-stream' + + if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) { + try { + const directRes = await fetchWithDNSPinning( + contentUrl, + accessToken, + 'teams-attachment' + ) + + if (directRes?.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else if (directRes) { + const encodedUrl = Buffer.from(contentUrl) + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + + const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content` + const graphRes = await fetch(graphUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (graphRes.ok) { + const arrayBuffer = await graphRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + graphRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } + } catch { + continue + } + } else if ( + contentUrl.includes('1drv.ms') || + contentUrl.includes('onedrive.live.com') || + contentUrl.includes('onedrive.com') || + contentUrl.includes('my.microsoftpersonalcontent.com') + ) { + try { + let shareToken: string | null = null + + if (contentUrl.includes('1drv.ms')) { + const urlParts = contentUrl.split('/').pop() + if (urlParts) shareToken = urlParts + } else if (contentUrl.includes('resid=')) { + const urlParams = new URL(contentUrl).searchParams + const resId = urlParams.get('resid') + if (resId) shareToken = resId + } + + if (!shareToken) { + const base64Url = Buffer.from(contentUrl, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } else if (!shareToken.startsWith('u!')) { + const base64Url = Buffer.from(shareToken, 'utf-8') + .toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') + shareToken = `u!${base64Url}` + } + + const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem` + const metadataRes = await fetch(metadataUrl, { + headers: { + Authorization: `Bearer ${accessToken}`, + Accept: 'application/json', + }, + }) + + if (!metadataRes.ok) { + const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content` + const directRes = await fetch(directUrl, { + headers: { Authorization: `Bearer ${accessToken}` }, + redirect: 'follow', + }) + + if (directRes.ok) { + const arrayBuffer = await directRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + directRes.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } else { + continue + } + } else { + const metadata = (await metadataRes.json()) as Record + const downloadUrl = metadata['@microsoft.graph.downloadUrl'] as + | string + | undefined + + if (downloadUrl) { + const downloadRes = await fetchWithDNSPinning( + downloadUrl, + '', + 'teams-onedrive-download' + ) + + if (downloadRes?.ok) { + const arrayBuffer = await downloadRes.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + const fileInfo = metadata.file as Record | undefined + mimeType = + downloadRes.headers.get('content-type') || + (fileInfo?.mimeType as string | undefined) || + contentTypeHint || + 'application/octet-stream' + + if (metadata.name && metadata.name !== attachmentName) { + attachmentName = metadata.name as string + } + } else { + continue + } + } else { + continue + } + } + } catch { + continue + } + } else { + try { + const ares = await fetchWithDNSPinning( + contentUrl, + accessToken, + 'teams-attachment-generic' + ) + if (ares?.ok) { + const arrayBuffer = await ares.arrayBuffer() + buffer = Buffer.from(arrayBuffer) + mimeType = + ares.headers.get('content-type') || + contentTypeHint || + 'application/octet-stream' + } + } catch { + continue + } + } + + if (!buffer) continue + + const size = buffer.length + + rawAttachments.push({ + name: attachmentName, + data: buffer, + contentType: mimeType, + size, + }) + } catch { + /* skip attachment on error */ + } + } + } + } + } + } catch (error) { + logger.error('Failed to fetch Teams message', { + error, + chatId: resolvedChatId, + messageId: resolvedMessageId, + }) + } + } + + if (!message) { + logger.warn('No message data available for Teams notification', { + chatId: resolvedChatId, + messageId: resolvedMessageId, + hasCredential: !!credentialId, + }) + return { + message_id: resolvedMessageId, + chat_id: resolvedChatId, + from_name: '', + text: '', + created_at: '', + attachments: [], + } + } + + const messageText = (message.body as Record)?.content || '' + const from = ((message.from as Record)?.user as Record) || {} + const createdAt = (message.createdDateTime as string) || '' + + return { + message_id: resolvedMessageId, + chat_id: resolvedChatId, + from_name: (from.displayName as string) || '', + text: messageText, + created_at: createdAt, + attachments: rawAttachments, + } +} + +export const microsoftTeamsHandler: WebhookProviderHandler = { + handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) { + const url = new URL(request.url) + const validationToken = url.searchParams.get('validationToken') + if (validationToken) { + logger.info(`[${requestId}] Microsoft Graph subscription validation for path: ${path}`) + return new NextResponse(validationToken, { + status: 200, + headers: { 'Content-Type': 'text/plain' }, + }) + } + return null + }, + + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + if (providerConfig.hmacSecret) { + const authHeader = request.headers.get('authorization') + + if (!authHeader || !authHeader.startsWith('HMAC ')) { + logger.warn( + `[${requestId}] Microsoft Teams outgoing webhook missing HMAC authorization header` + ) + return new NextResponse('Unauthorized - Missing HMAC signature', { status: 401 }) + } + + if ( + !validateMicrosoftTeamsSignature(providerConfig.hmacSecret as string, authHeader, rawBody) + ) { + logger.warn(`[${requestId}] Microsoft Teams HMAC signature verification failed`) + return new NextResponse('Unauthorized - Invalid HMAC signature', { status: 401 }) + } + } + + return null + }, + + formatErrorResponse(error: string, status: number) { + return NextResponse.json({ type: 'message', text: error }, { status }) + }, + + enrichHeaders({ body }: EventFilterContext, headers: Record) { + const parsed = parseFirstNotification(body) + if (parsed) { + headers['x-teams-notification-id'] = `${parsed.subscriptionId}:${parsed.messageId}` + } + }, + + extractIdempotencyId(body: unknown) { + const parsed = parseFirstNotification(body) + return parsed ? `${parsed.subscriptionId}:${parsed.messageId}` : null + }, + + formatSuccessResponse(providerConfig: Record) { + if (providerConfig.triggerId === 'microsoftteams_chat_subscription') { + return new NextResponse(null, { status: 202 }) + } + + return NextResponse.json({ type: 'message', text: 'Sim' }) + }, + + formatQueueErrorResponse() { + return NextResponse.json( + { type: 'message', text: 'Webhook processing failed' }, + { status: 500 } + ) + }, + + async createSubscription({ + webhook, + workflow, + userId, + requestId, + request, + }: SubscriptionContext): Promise { + const config = getProviderConfig(webhook) + + if (config.triggerId !== 'microsoftteams_chat_subscription') { + return undefined + } + + const credentialId = config.credentialId as string | undefined + const chatId = config.chatId as string | undefined + + if (!credentialId) { + logger.warn(`[${requestId}] Missing credentialId for Teams chat subscription ${webhook.id}`) + throw new Error( + 'Microsoft Teams credentials are required. Please connect your Microsoft account in the trigger configuration.' + ) + } + + if (!chatId) { + logger.warn(`[${requestId}] Missing chatId for Teams chat subscription ${webhook.id}`) + throw new Error( + 'Chat ID is required to create a Teams subscription. Please provide a valid chat ID.' + ) + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.error(`[${requestId}] Failed to get access token for Teams subscription ${webhook.id}`) + throw new Error( + 'Failed to authenticate with Microsoft Teams. Please reconnect your Microsoft account and try again.' + ) + } + + const existingSubscriptionId = config.externalSubscriptionId as string | undefined + if (existingSubscriptionId) { + try { + const checkRes = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${existingSubscriptionId}`, + { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } } + ) + if (checkRes.ok) { + logger.info( + `[${requestId}] Teams subscription ${existingSubscriptionId} already exists for webhook ${webhook.id}` + ) + return { providerConfigUpdates: { externalSubscriptionId: existingSubscriptionId } } + } + } catch { + logger.debug(`[${requestId}] Existing subscription check failed, will create new one`) + } + } + + const notificationUrl = getNotificationUrl(webhook) + const resource = `/chats/${chatId}/messages` + + const maxLifetimeMinutes = 4230 + const expirationDateTime = new Date(Date.now() + maxLifetimeMinutes * 60 * 1000).toISOString() + + const body = { + changeType: 'created,updated', + notificationUrl, + lifecycleNotificationUrl: notificationUrl, + resource, + includeResourceData: false, + expirationDateTime, + clientState: webhook.id, + } + + try { + const res = await fetch('https://graph.microsoft.com/v1.0/subscriptions', { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }) + + const payload = await res.json() + if (!res.ok) { + const errorMessage = + payload.error?.message || payload.error?.code || 'Unknown Microsoft Graph API error' + logger.error( + `[${requestId}] Failed to create Teams subscription for webhook ${webhook.id}`, + { + status: res.status, + error: payload.error, + } + ) + + let userFriendlyMessage = 'Failed to create Teams subscription' + if (res.status === 401 || res.status === 403) { + userFriendlyMessage = + 'Authentication failed. Please reconnect your Microsoft Teams account and ensure you have the necessary permissions.' + } else if (res.status === 404) { + userFriendlyMessage = + 'Chat not found. Please verify that the Chat ID is correct and that you have access to the specified chat.' + } else if (errorMessage && errorMessage !== 'Unknown Microsoft Graph API error') { + userFriendlyMessage = `Teams error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${requestId}] Successfully created Teams subscription ${payload.id} for webhook ${webhook.id}` + ) + return { providerConfigUpdates: { externalSubscriptionId: payload.id as string } } + } catch (error: unknown) { + if ( + error instanceof Error && + (error.message.includes('credentials') || + error.message.includes('Chat ID') || + error.message.includes('authenticate')) + ) { + throw error + } + + logger.error( + `[${requestId}] Error creating Teams subscription for webhook ${webhook.id}`, + error + ) + throw new Error( + error instanceof Error + ? error.message + : 'Failed to create Teams subscription. Please try again.' + ) + } + }, + + async deleteSubscription({ + webhook, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhook) + + if (config.triggerId !== 'microsoftteams_chat_subscription') { + return + } + + const externalSubscriptionId = config.externalSubscriptionId as string | undefined + const credentialId = config.credentialId as string | undefined + + if (!externalSubscriptionId || !credentialId) { + logger.info(`[${requestId}] No external subscription to delete for webhook ${webhook.id}`) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.warn( + `[${requestId}] Could not get access token to delete Teams subscription for webhook ${webhook.id}` + ) + return + } + + const res = await fetch( + `https://graph.microsoft.com/v1.0/subscriptions/${externalSubscriptionId}`, + { + method: 'DELETE', + headers: { Authorization: `Bearer ${accessToken}` }, + } + ) + + if (res.ok || res.status === 404) { + logger.info( + `[${requestId}] Successfully deleted Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}` + ) + } else { + const errorBody = await res.text() + logger.warn( + `[${requestId}] Failed to delete Teams subscription ${externalSubscriptionId} for webhook ${webhook.id}. Status: ${res.status}` + ) + } + } catch (error) { + logger.error( + `[${requestId}] Error deleting Teams subscription for webhook ${webhook.id}`, + error + ) + } + }, + + async formatInput({ + body, + webhook, + workflow, + headers, + requestId, + }: FormatInputContext): Promise { + const b = body as Record + const value = b?.value as unknown[] | undefined + + if (value && Array.isArray(value) && value.length > 0) { + const mockRequest = { + headers: new Map(Object.entries(headers)), + } as unknown as import('next/server').NextRequest + const result = await formatTeamsGraphNotification( + b, + webhook, + workflow, + mockRequest as unknown as { headers: Map } + ) + return { input: result } + } + + const messageText = (b?.text as string) || '' + const messageId = (b?.id as string) || '' + const timestamp = (b?.timestamp as string) || (b?.localTimestamp as string) || '' + const from = (b?.from || {}) as Record + const conversation = (b?.conversation || {}) as Record + + return { + input: { + from: { + id: (from.id || '') as string, + name: (from.name || '') as string, + aadObjectId: (from.aadObjectId || '') as string, + }, + message: { + raw: { + attachments: b?.attachments || [], + channelData: b?.channelData || {}, + conversation: b?.conversation || {}, + text: messageText, + messageType: (b?.type || 'message') as string, + channelId: (b?.channelId || '') as string, + timestamp, + }, + }, + activity: b || {}, + conversation: { + id: (conversation.id || '') as string, + name: (conversation.name || '') as string, + isGroup: (conversation.isGroup || false) as boolean, + tenantId: (conversation.tenantId || '') as string, + aadObjectId: (conversation.aadObjectId || '') as string, + conversationType: (conversation.conversationType || '') as string, + }, + }, + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/outlook.ts b/apps/sim/lib/webhooks/providers/outlook.ts new file mode 100644 index 00000000000..01f9656bc15 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/outlook.ts @@ -0,0 +1,113 @@ +import { db } from '@sim/db' +import { account, webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { + FormatInputContext, + FormatInputResult, + PollingConfigContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { refreshAccessTokenIfNeeded, resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Outlook') + +export const outlookHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'email' in b) { + return { input: { email: b.email, timestamp: b.timestamp } } + } + return { input: b } + }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const credentialId = providerConfig.credentialId as string | undefined + + if (!credentialId) { + logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) + return false + } + + const resolvedOutlook = await resolveOAuthAccountId(credentialId) + if (!resolvedOutlook) { + logger.error( + `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` + ) + return false + } + + const rows = await db + .select() + .from(account) + .where(eq(account.id, resolvedOutlook.accountId)) + .limit(1) + if (rows.length === 0) { + logger.error( + `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` + ) + return false + } + + const effectiveUserId = rows[0].userId + + const accessToken = await refreshAccessTokenIfNeeded( + resolvedOutlook.accountId, + effectiveUserId, + requestId + ) + if (!accessToken) { + logger.error( + `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` + ) + return false + } + + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + userId: effectiveUserId, + credentialId, + maxEmailsPerPoll: + typeof providerConfig.maxEmailsPerPoll === 'string' + ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 + : (providerConfig.maxEmailsPerPoll as number) || 25, + pollingInterval: + typeof providerConfig.pollingInterval === 'string' + ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 + : (providerConfig.pollingInterval as number) || 5, + markAsRead: providerConfig.markAsRead || false, + includeRawEmail: providerConfig.includeRawEmail || false, + folderIds: providerConfig.folderIds || ['inbox'], + folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', + lastCheckedTimestamp: + (providerConfig.lastCheckedTimestamp as string) || now.toISOString(), + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure Outlook polling`, { + webhookId: webhookData.id, + error: err.message, + stack: err.stack, + }) + return false + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts new file mode 100644 index 00000000000..00ae58a21b1 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -0,0 +1,91 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { airtableHandler } from '@/lib/webhooks/providers/airtable' +import { ashbyHandler } from '@/lib/webhooks/providers/ashby' +import { attioHandler } from '@/lib/webhooks/providers/attio' +import { calcomHandler } from '@/lib/webhooks/providers/calcom' +import { calendlyHandler } from '@/lib/webhooks/providers/calendly' +import { circlebackHandler } from '@/lib/webhooks/providers/circleback' +import { confluenceHandler } from '@/lib/webhooks/providers/confluence' +import { fathomHandler } from '@/lib/webhooks/providers/fathom' +import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' +import { genericHandler } from '@/lib/webhooks/providers/generic' +import { githubHandler } from '@/lib/webhooks/providers/github' +import { gmailHandler } from '@/lib/webhooks/providers/gmail' +import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' +import { grainHandler } from '@/lib/webhooks/providers/grain' +import { hubspotHandler } from '@/lib/webhooks/providers/hubspot' +import { imapHandler } from '@/lib/webhooks/providers/imap' +import { jiraHandler } from '@/lib/webhooks/providers/jira' +import { lemlistHandler } from '@/lib/webhooks/providers/lemlist' +import { linearHandler } from '@/lib/webhooks/providers/linear' +import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' +import { outlookHandler } from '@/lib/webhooks/providers/outlook' +import { rssHandler } from '@/lib/webhooks/providers/rss' +import { slackHandler } from '@/lib/webhooks/providers/slack' +import { stripeHandler } from '@/lib/webhooks/providers/stripe' +import { telegramHandler } from '@/lib/webhooks/providers/telegram' +import { twilioHandler } from '@/lib/webhooks/providers/twilio' +import { twilioVoiceHandler } from '@/lib/webhooks/providers/twilio-voice' +import { typeformHandler } from '@/lib/webhooks/providers/typeform' +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' + +const logger = createLogger('WebhookProviderRegistry') + +const PROVIDER_HANDLERS: Record = { + airtable: airtableHandler, + ashby: ashbyHandler, + attio: attioHandler, + calendly: calendlyHandler, + calcom: calcomHandler, + circleback: circlebackHandler, + confluence: confluenceHandler, + fireflies: firefliesHandler, + generic: genericHandler, + gmail: gmailHandler, + github: githubHandler, + google_forms: googleFormsHandler, + fathom: fathomHandler, + grain: grainHandler, + hubspot: hubspotHandler, + imap: imapHandler, + jira: jiraHandler, + lemlist: lemlistHandler, + linear: linearHandler, + 'microsoft-teams': microsoftTeamsHandler, + outlook: outlookHandler, + rss: rssHandler, + slack: slackHandler, + stripe: stripeHandler, + telegram: telegramHandler, + twilio: twilioHandler, + twilio_voice: twilioVoiceHandler, + typeform: typeformHandler, + webflow: webflowHandler, + whatsapp: whatsappHandler, +} + +/** + * Default handler for unknown/future providers. + * Uses timing-safe comparison for bearer token validation. + */ +const defaultHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId, providerConfig }) { + const token = providerConfig.token + if (typeof token === 'string') { + if (!verifyTokenAuth(request, token)) { + logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) + return new NextResponse('Unauthorized', { status: 401 }) + } + } + return null + }, +} + +/** Look up the provider handler, falling back to the default bearer token handler. */ +export function getProviderHandler(provider: string): WebhookProviderHandler { + return PROVIDER_HANDLERS[provider] ?? defaultHandler +} diff --git a/apps/sim/lib/webhooks/providers/rss.ts b/apps/sim/lib/webhooks/providers/rss.ts new file mode 100644 index 00000000000..e517fd1cda1 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/rss.ts @@ -0,0 +1,65 @@ +import { db } from '@sim/db' +import { webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import type { + FormatInputContext, + FormatInputResult, + PollingConfigContext, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Rss') + +export const rssHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + if (b && typeof b === 'object' && 'item' in b) { + return { + input: { + title: b.title, + link: b.link, + pubDate: b.pubDate, + item: b.item, + feed: b.feed, + timestamp: b.timestamp, + }, + } + } + return { input: b } + }, + + async configurePolling({ webhook: webhookData, requestId }: PollingConfigContext) { + logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) + + try { + const providerConfig = (webhookData.providerConfig as Record) || {} + const now = new Date() + + await db + .update(webhook) + .set({ + providerConfig: { + ...providerConfig, + lastCheckedTimestamp: now.toISOString(), + lastSeenGuids: [], + setupCompleted: true, + }, + updatedAt: now, + }) + .where(eq(webhook.id, webhookData.id as string)) + + logger.info( + `[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}` + ) + return true + } catch (error: unknown) { + const err = error as Error + logger.error(`[${requestId}] Failed to configure RSS polling`, { + webhookId: webhookData.id, + error: err.message, + }) + return false + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/slack.ts b/apps/sim/lib/webhooks/providers/slack.ts new file mode 100644 index 00000000000..1bcedd628b9 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/slack.ts @@ -0,0 +1,282 @@ +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { + secureFetchWithPinnedIP, + validateUrlWithDNS, +} from '@/lib/core/security/input-validation.server' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Slack') + +const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB +const SLACK_MAX_FILES = 15 + +const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed']) + +async function resolveSlackFileInfo( + fileId: string, + botToken: string +): Promise<{ url_private?: string; name?: string; mimetype?: string; size?: number } | null> { + try { + const response = await fetch( + `https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`, + { headers: { Authorization: `Bearer ${botToken}` } } + ) + const data = (await response.json()) as { + ok: boolean + error?: string + file?: Record + } + if (!data.ok || !data.file) { + logger.warn('Slack files.info failed', { fileId, error: data.error }) + return null + } + return { + url_private: data.file.url_private as string | undefined, + name: data.file.name as string | undefined, + mimetype: data.file.mimetype as string | undefined, + size: data.file.size as number | undefined, + } + } catch (error) { + logger.error('Error calling Slack files.info', { + fileId, + error: error instanceof Error ? error.message : String(error), + }) + return null + } +} + +async function downloadSlackFiles( + rawFiles: unknown[], + botToken: string +): Promise> { + const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES) + const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = [] + + for (const file of filesToProcess) { + const f = file as Record + let urlPrivate = f.url_private as string | undefined + let fileName = f.name as string | undefined + let fileMimeType = f.mimetype as string | undefined + let fileSize = f.size as number | undefined + + if (!urlPrivate && f.id) { + const resolved = await resolveSlackFileInfo(f.id as string, botToken) + if (resolved?.url_private) { + urlPrivate = resolved.url_private + fileName = fileName || resolved.name + fileMimeType = fileMimeType || resolved.mimetype + fileSize = fileSize ?? resolved.size + } + } + + if (!urlPrivate) { + logger.warn('Slack file has no url_private and could not be resolved, skipping', { + fileId: f.id, + }) + continue + } + + const reportedSize = Number(fileSize) || 0 + if (reportedSize > SLACK_MAX_FILE_SIZE) { + logger.warn('Slack file exceeds size limit, skipping', { + fileId: f.id, + size: reportedSize, + limit: SLACK_MAX_FILE_SIZE, + }) + continue + } + + try { + const urlValidation = await validateUrlWithDNS(urlPrivate, 'url_private') + if (!urlValidation.isValid) { + logger.warn('Slack file url_private failed DNS validation, skipping', { + fileId: f.id, + error: urlValidation.error, + }) + continue + } + + const response = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, { + headers: { Authorization: `Bearer ${botToken}` }, + }) + + if (!response.ok) { + logger.warn('Failed to download Slack file, skipping', { + fileId: f.id, + status: response.status, + }) + continue + } + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + if (buffer.length > SLACK_MAX_FILE_SIZE) { + logger.warn('Downloaded Slack file exceeds size limit, skipping', { + fileId: f.id, + actualSize: buffer.length, + limit: SLACK_MAX_FILE_SIZE, + }) + continue + } + + downloaded.push({ + name: fileName || 'download', + data: buffer.toString('base64'), + mimeType: fileMimeType || 'application/octet-stream', + size: buffer.length, + }) + } catch (error) { + logger.error('Error downloading Slack file, skipping', { + fileId: f.id, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + return downloaded +} + +async function fetchSlackMessageText( + channel: string, + messageTs: string, + botToken: string +): Promise { + try { + const params = new URLSearchParams({ channel, timestamp: messageTs }) + const response = await fetch(`https://slack.com/api/reactions.get?${params}`, { + headers: { Authorization: `Bearer ${botToken}` }, + }) + const data = (await response.json()) as { + ok: boolean + error?: string + type?: string + message?: { text?: string } + } + if (!data.ok) { + logger.warn('Slack reactions.get failed — message text unavailable', { + channel, + messageTs, + error: data.error, + }) + return '' + } + return data.message?.text ?? '' + } catch (error) { + logger.warn('Error fetching Slack message text', { + channel, + messageTs, + error: error instanceof Error ? error.message : String(error), + }) + return '' + } +} + +/** + * Handle Slack verification challenges + */ +export function handleSlackChallenge(body: unknown): NextResponse | null { + const obj = body as Record + if (obj.type === 'url_verification' && obj.challenge) { + return NextResponse.json({ challenge: obj.challenge }) + } + + return null +} + +export const slackHandler: WebhookProviderHandler = { + handleChallenge(body: unknown) { + return handleSlackChallenge(body) + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj.event_id) { + return String(obj.event_id) + } + + const event = obj.event as Record | undefined + if (event?.ts && obj.team_id) { + return `${obj.team_id}:${event.ts}` + } + + return null + }, + + formatSuccessResponse() { + return new NextResponse(null, { status: 200 }) + }, + + formatQueueErrorResponse() { + return new NextResponse(null, { status: 200 }) + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const botToken = providerConfig.botToken as string | undefined + const includeFiles = Boolean(providerConfig.includeFiles) + + const rawEvent = b?.event as Record | undefined + + if (!rawEvent) { + logger.warn('Unknown Slack event type', { + type: b?.type, + hasEvent: false, + bodyKeys: Object.keys(b || {}), + }) + } + + const eventType: string = (rawEvent?.type as string) || (b?.type as string) || 'unknown' + const isReactionEvent = SLACK_REACTION_EVENTS.has(eventType) + + const item = rawEvent?.item as Record | undefined + const channel: string = isReactionEvent + ? (item?.channel as string) || '' + : (rawEvent?.channel as string) || '' + const messageTs: string = isReactionEvent + ? (item?.ts as string) || '' + : (rawEvent?.ts as string) || (rawEvent?.event_ts as string) || '' + + let text: string = (rawEvent?.text as string) || '' + if (isReactionEvent && channel && messageTs && botToken) { + text = await fetchSlackMessageText(channel, messageTs, botToken) + } + + const rawFiles: unknown[] = (rawEvent?.files as unknown[]) ?? [] + const hasFiles = rawFiles.length > 0 + + let files: Array<{ name: string; data: string; mimeType: string; size: number }> = [] + if (hasFiles && includeFiles && botToken) { + files = await downloadSlackFiles(rawFiles, botToken) + } else if (hasFiles && includeFiles && !botToken) { + logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided') + } + + return { + input: { + event: { + event_type: eventType, + channel, + channel_name: '', + user: (rawEvent?.user as string) || '', + user_name: '', + text, + timestamp: messageTs, + thread_ts: (rawEvent?.thread_ts as string) || '', + team_id: (b?.team_id as string) || (rawEvent?.team as string) || '', + event_id: (b?.event_id as string) || '', + reaction: (rawEvent?.reaction as string) || '', + item_user: (rawEvent?.item_user as string) || '', + hasFiles, + files, + }, + }, + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/stripe.ts b/apps/sim/lib/webhooks/providers/stripe.ts new file mode 100644 index 00000000000..7b9414ab788 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/stripe.ts @@ -0,0 +1,28 @@ +import { createLogger } from '@sim/logger' +import type { + EventFilterContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { skipByEventTypes } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Stripe') + +export const stripeHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + return { input: body } + }, + + shouldSkipEvent(ctx: EventFilterContext) { + return skipByEventTypes(ctx, 'Stripe', logger) + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj.id && obj.object === 'event') { + return String(obj.id) + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/subscription-utils.ts b/apps/sim/lib/webhooks/providers/subscription-utils.ts new file mode 100644 index 00000000000..17c6ca29514 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/subscription-utils.ts @@ -0,0 +1,39 @@ +import { db } from '@sim/db' +import { account } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProviderSubscriptions') + +export function getProviderConfig(webhook: Record): Record { + return (webhook.providerConfig as Record) || {} +} + +export function getNotificationUrl(webhook: Record): string { + return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}` +} + +export async function getCredentialOwner( + credentialId: string, + requestId: string +): Promise<{ userId: string; accountId: string } | null> { + const resolved = await resolveOAuthAccountId(credentialId) + if (!resolved) { + logger.warn(`[${requestId}] Failed to resolve OAuth account for credentialId ${credentialId}`) + return null + } + const [credentialRecord] = await db + .select({ userId: account.userId }) + .from(account) + .where(eq(account.id, resolved.accountId)) + .limit(1) + + if (!credentialRecord?.userId) { + logger.warn(`[${requestId}] Credential owner not found for credentialId ${credentialId}`) + return null + } + + return { userId: credentialRecord.userId, accountId: resolved.accountId } +} diff --git a/apps/sim/lib/webhooks/providers/telegram.ts b/apps/sim/lib/webhooks/providers/telegram.ts new file mode 100644 index 00000000000..8511f9b1198 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/telegram.ts @@ -0,0 +1,205 @@ +import { createLogger } from '@sim/logger' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Telegram') + +export const telegramHandler: WebhookProviderHandler = { + verifyAuth({ request, requestId }: AuthContext) { + const userAgent = request.headers.get('user-agent') + if (!userAgent) { + logger.warn( + `[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.` + ) + } + return null + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + const rawMessage = (b?.message || + b?.edited_message || + b?.channel_post || + b?.edited_channel_post) as Record | undefined + + const updateType = b.message + ? 'message' + : b.edited_message + ? 'edited_message' + : b.channel_post + ? 'channel_post' + : b.edited_channel_post + ? 'edited_channel_post' + : 'unknown' + + if (rawMessage) { + const messageType = rawMessage.photo + ? 'photo' + : rawMessage.document + ? 'document' + : rawMessage.audio + ? 'audio' + : rawMessage.video + ? 'video' + : rawMessage.voice + ? 'voice' + : rawMessage.sticker + ? 'sticker' + : rawMessage.location + ? 'location' + : rawMessage.contact + ? 'contact' + : rawMessage.poll + ? 'poll' + : 'text' + + const from = rawMessage.from as Record | undefined + return { + input: { + message: { + id: rawMessage.message_id, + text: rawMessage.text, + date: rawMessage.date, + messageType, + raw: rawMessage, + }, + sender: from + ? { + id: from.id, + username: from.username, + firstName: from.first_name, + lastName: from.last_name, + languageCode: from.language_code, + isBot: from.is_bot, + } + : null, + updateId: b.update_id, + updateType, + }, + } + } + + logger.warn('Unknown Telegram update type', { + updateId: b.update_id, + bodyKeys: Object.keys(b || {}), + }) + + return { + input: { + updateId: b.update_id, + updateType, + }, + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const botToken = config.botToken as string | undefined + + if (!botToken) { + logger.warn(`[${ctx.requestId}] Missing botToken for Telegram webhook ${ctx.webhook.id}`) + throw new Error( + 'Bot token is required to create a Telegram webhook. Please provide a valid Telegram bot token.' + ) + } + + const notificationUrl = getNotificationUrl(ctx.webhook) + const telegramApiUrl = `https://api.telegram.org/bot${botToken}/setWebhook` + + try { + const telegramResponse = await fetch(telegramApiUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'TelegramBot/1.0', + }, + body: JSON.stringify({ url: notificationUrl }), + }) + + const responseBody = await telegramResponse.json() + if (!telegramResponse.ok || !responseBody.ok) { + const errorMessage = + responseBody.description || + `Failed to create Telegram webhook. Status: ${telegramResponse.status}` + logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody }) + + let userFriendlyMessage = 'Failed to create Telegram webhook' + if (telegramResponse.status === 401) { + userFriendlyMessage = + 'Invalid bot token. Please verify that the bot token is correct and try again.' + } else if (responseBody.description) { + userFriendlyMessage = `Telegram error: ${responseBody.description}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${ctx.requestId}] Successfully created Telegram webhook for webhook ${ctx.webhook.id}` + ) + return {} + } catch (error: unknown) { + if ( + error instanceof Error && + (error.message.includes('Bot token') || error.message.includes('Telegram error')) + ) { + throw error + } + + logger.error( + `[${ctx.requestId}] Error creating Telegram webhook for webhook ${ctx.webhook.id}`, + error + ) + throw new Error( + error instanceof Error + ? error.message + : 'Failed to create Telegram webhook. Please try again.' + ) + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const botToken = config.botToken as string | undefined + + if (!botToken) { + logger.warn( + `[${ctx.requestId}] Missing botToken for Telegram webhook deletion ${ctx.webhook.id}` + ) + return + } + + const telegramApiUrl = `https://api.telegram.org/bot${botToken}/deleteWebhook` + const telegramResponse = await fetch(telegramApiUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }) + + const responseBody = await telegramResponse.json() + if (!telegramResponse.ok || !responseBody.ok) { + const errorMessage = + responseBody.description || + `Failed to delete Telegram webhook. Status: ${telegramResponse.status}` + logger.error(`[${ctx.requestId}] ${errorMessage}`, { response: responseBody }) + } else { + logger.info( + `[${ctx.requestId}] Successfully deleted Telegram webhook for webhook ${ctx.webhook.id}` + ) + } + } catch (error) { + logger.error( + `[${ctx.requestId}] Error deleting Telegram webhook for webhook ${ctx.webhook.id}`, + error + ) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/twilio-voice.ts b/apps/sim/lib/webhooks/providers/twilio-voice.ts new file mode 100644 index 00000000000..be0417ef71d --- /dev/null +++ b/apps/sim/lib/webhooks/providers/twilio-voice.ts @@ -0,0 +1,214 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + AuthContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { convertSquareBracketsToTwiML } from '@/lib/webhooks/utils' + +const logger = createLogger('WebhookProvider:TwilioVoice') + +async function validateTwilioSignature( + authToken: string, + signature: string, + url: string, + params: Record +): Promise { + try { + if (!authToken || !signature || !url) { + logger.warn('Twilio signature validation missing required fields', { + hasAuthToken: !!authToken, + hasSignature: !!signature, + hasUrl: !!url, + }) + return false + } + const sortedKeys = Object.keys(params).sort() + let data = url + for (const key of sortedKeys) { + data += key + params[key] + } + logger.debug('Twilio signature validation string built', { + url, + sortedKeys, + dataLength: data.length, + }) + const encoder = new TextEncoder() + const key = await crypto.subtle.importKey( + 'raw', + encoder.encode(authToken), + { name: 'HMAC', hash: 'SHA-1' }, + false, + ['sign'] + ) + const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data)) + const signatureArray = Array.from(new Uint8Array(signatureBytes)) + const signatureBase64 = btoa(String.fromCharCode(...signatureArray)) + logger.debug('Twilio signature comparison', { + computedSignature: `${signatureBase64.substring(0, 10)}...`, + providedSignature: `${signature.substring(0, 10)}...`, + computedLength: signatureBase64.length, + providedLength: signature.length, + match: signatureBase64 === signature, + }) + return safeCompare(signatureBase64, signature) + } catch (error) { + logger.error('Error validating Twilio signature:', error) + return false + } +} + +function getExternalUrl(request: Request): string { + const proto = request.headers.get('x-forwarded-proto') || 'https' + const host = request.headers.get('x-forwarded-host') || request.headers.get('host') + + if (host) { + const url = new URL(request.url) + const reconstructed = `${proto}://${host}${url.pathname}${url.search}` + return reconstructed + } + + return request.url +} + +export const twilioVoiceHandler: WebhookProviderHandler = { + async verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const authToken = providerConfig.authToken as string | undefined + + if (authToken) { + const signature = request.headers.get('x-twilio-signature') + + if (!signature) { + logger.warn(`[${requestId}] Twilio Voice webhook missing signature header`) + return new NextResponse('Unauthorized - Missing Twilio signature', { + status: 401, + }) + } + + let params: Record = {} + try { + if (typeof rawBody === 'string') { + const urlParams = new URLSearchParams(rawBody) + params = Object.fromEntries(urlParams.entries()) + } + } catch (error) { + logger.error( + `[${requestId}] Error parsing Twilio webhook body for signature validation:`, + error + ) + return new NextResponse('Bad Request - Invalid body format', { + status: 400, + }) + } + + const fullUrl = getExternalUrl(request) + const isValidSignature = await validateTwilioSignature(authToken, signature, fullUrl, params) + + if (!isValidSignature) { + logger.warn(`[${requestId}] Twilio Voice signature verification failed`, { + url: fullUrl, + signatureLength: signature.length, + paramsCount: Object.keys(params).length, + authTokenLength: authToken.length, + }) + return new NextResponse('Unauthorized - Invalid Twilio signature', { + status: 401, + }) + } + } + + return null + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + return (obj.MessageSid as string) || (obj.CallSid as string) || null + }, + + formatSuccessResponse(providerConfig: Record) { + const twimlResponse = (providerConfig.twimlResponse as string | undefined)?.trim() + + if (twimlResponse && twimlResponse.length > 0) { + const convertedTwiml = convertSquareBracketsToTwiML(twimlResponse) + return new NextResponse(convertedTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + }) + } + + const defaultTwiml = ` + + Your call is being processed. + +` + + return new NextResponse(defaultTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml; charset=utf-8', + }, + }) + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + callSid: b.CallSid, + accountSid: b.AccountSid, + from: b.From, + to: b.To, + callStatus: b.CallStatus, + direction: b.Direction, + apiVersion: b.ApiVersion, + callerName: b.CallerName, + forwardedFrom: b.ForwardedFrom, + digits: b.Digits, + speechResult: b.SpeechResult, + recordingUrl: b.RecordingUrl, + recordingSid: b.RecordingSid, + called: b.Called, + caller: b.Caller, + toCity: b.ToCity, + toState: b.ToState, + toZip: b.ToZip, + toCountry: b.ToCountry, + fromCity: b.FromCity, + fromState: b.FromState, + fromZip: b.FromZip, + fromCountry: b.FromCountry, + calledCity: b.CalledCity, + calledState: b.CalledState, + calledZip: b.CalledZip, + calledCountry: b.CalledCountry, + callerCity: b.CallerCity, + callerState: b.CallerState, + callerZip: b.CallerZip, + callerCountry: b.CallerCountry, + callToken: b.CallToken, + raw: JSON.stringify(b), + }, + } + }, + + formatQueueErrorResponse() { + const errorTwiml = ` + + We're sorry, but an error occurred processing your call. Please try again later. + +` + + return new NextResponse(errorTwiml, { + status: 200, + headers: { + 'Content-Type': 'text/xml', + }, + }) + }, +} diff --git a/apps/sim/lib/webhooks/providers/twilio.ts b/apps/sim/lib/webhooks/providers/twilio.ts new file mode 100644 index 00000000000..3ba33decc11 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/twilio.ts @@ -0,0 +1,8 @@ +import type { WebhookProviderHandler } from '@/lib/webhooks/providers/types' + +export const twilioHandler: WebhookProviderHandler = { + extractIdempotencyId(body: unknown) { + const obj = body as Record + return (obj.MessageSid as string) || (obj.CallSid as string) || null + }, +} diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts new file mode 100644 index 00000000000..068c72d9cd4 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/typeform.ts @@ -0,0 +1,213 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { safeCompare } from '@/lib/core/security/encryption' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Typeform') + +function validateTypeformSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + return false + } + if (!signature.startsWith('sha256=')) { + return false + } + const providedSignature = signature.substring(7) + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Typeform signature:', error) + return false + } +} + +export const typeformHandler: WebhookProviderHandler = { + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const formResponse = (b?.form_response || {}) as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const includeDefinition = providerConfig.includeDefinition === true + return { + input: { + event_id: b?.event_id || '', + event_type: b?.event_type || 'form_response', + form_id: formResponse.form_id || '', + token: formResponse.token || '', + submitted_at: formResponse.submitted_at || '', + landed_at: formResponse.landed_at || '', + calculated: formResponse.calculated || {}, + variables: formResponse.variables || [], + hidden: formResponse.hidden || {}, + answers: formResponse.answers || [], + ...(includeDefinition ? { definition: formResponse.definition || {} } : {}), + ending: formResponse.ending || {}, + raw: b, + }, + } + }, + + verifyAuth: createHmacVerifier({ + configKey: 'secret', + headerName: 'Typeform-Signature', + validateFn: validateTypeformSignature, + providerLabel: 'Typeform', + }), + + async createSubscription(ctx: SubscriptionContext): Promise { + const config = getProviderConfig(ctx.webhook) + const formId = config.formId as string | undefined + const apiKey = config.apiKey as string | undefined + const webhookTag = config.webhookTag as string | undefined + const secret = config.secret as string | undefined + + if (!formId) { + logger.warn(`[${ctx.requestId}] Missing formId for Typeform webhook ${ctx.webhook.id}`) + throw new Error( + 'Form ID is required to create a Typeform webhook. Please provide a valid form ID.' + ) + } + + if (!apiKey) { + logger.warn(`[${ctx.requestId}] Missing apiKey for Typeform webhook ${ctx.webhook.id}`) + throw new Error( + 'Personal Access Token is required to create a Typeform webhook. Please provide your Typeform API key.' + ) + } + + const tag = webhookTag || `sim-${(ctx.webhook.id as string).substring(0, 8)}` + const notificationUrl = getNotificationUrl(ctx.webhook) + + try { + const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` + + const requestBody: Record = { + url: notificationUrl, + enabled: true, + verify_ssl: true, + event_types: { + form_response: true, + }, + } + + if (secret) { + requestBody.secret = secret + } + + const typeformResponse = await fetch(typeformApiUrl, { + method: 'PUT', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + if (!typeformResponse.ok) { + const responseBody = await typeformResponse.json().catch(() => ({})) + const errorMessage = + (responseBody as Record).description || + (responseBody as Record).message || + 'Unknown error' + + logger.error(`[${ctx.requestId}] Typeform API error: ${errorMessage}`, { + status: typeformResponse.status, + response: responseBody, + }) + + let userFriendlyMessage = 'Failed to create Typeform webhook' + if (typeformResponse.status === 401) { + userFriendlyMessage = + 'Invalid Personal Access Token. Please verify your Typeform API key and try again.' + } else if (typeformResponse.status === 403) { + userFriendlyMessage = + 'Access denied. Please ensure you have a Typeform PRO or PRO+ account and the API key has webhook permissions.' + } else if (typeformResponse.status === 404) { + userFriendlyMessage = 'Form not found. Please verify the form ID is correct.' + } else if ( + (responseBody as Record).description || + (responseBody as Record).message + ) { + userFriendlyMessage = `Typeform error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + const responseBody = await typeformResponse.json() + logger.info( + `[${ctx.requestId}] Successfully created Typeform webhook for webhook ${ctx.webhook.id} with tag ${tag}`, + { webhookId: (responseBody as Record).id } + ) + + if (!webhookTag && tag) { + return { providerConfigUpdates: { webhookTag: tag } } + } + return {} + } catch (error: unknown) { + if ( + error instanceof Error && + (error.message.includes('Form ID') || + error.message.includes('Personal Access Token') || + error.message.includes('Typeform error')) + ) { + throw error + } + + logger.error( + `[${ctx.requestId}] Error creating Typeform webhook for webhook ${ctx.webhook.id}`, + error + ) + throw new Error( + error instanceof Error + ? error.message + : 'Failed to create Typeform webhook. Please try again.' + ) + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(ctx.webhook) + const formId = config.formId as string | undefined + const apiKey = config.apiKey as string | undefined + const webhookTag = config.webhookTag as string | undefined + + if (!formId || !apiKey) { + logger.warn( + `[${ctx.requestId}] Missing formId or apiKey for Typeform webhook deletion ${ctx.webhook.id}, skipping cleanup` + ) + return + } + + const tag = webhookTag || `sim-${(ctx.webhook.id as string).substring(0, 8)}` + const typeformApiUrl = `https://api.typeform.com/forms/${formId}/webhooks/${tag}` + + const typeformResponse = await fetch(typeformApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!typeformResponse.ok && typeformResponse.status !== 404) { + logger.warn( + `[${ctx.requestId}] Failed to delete Typeform webhook (non-fatal): ${typeformResponse.status}` + ) + } else { + logger.info(`[${ctx.requestId}] Successfully deleted Typeform webhook with tag ${tag}`) + } + } catch (error) { + logger.warn(`[${ctx.requestId}] Error deleting Typeform webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts new file mode 100644 index 00000000000..34587ce7a42 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/types.ts @@ -0,0 +1,143 @@ +import type { NextRequest, NextResponse } from 'next/server' + +/** Context for signature/token verification. */ +export interface AuthContext { + webhook: Record + workflow: Record + request: NextRequest + rawBody: string + requestId: string + providerConfig: Record +} + +/** Context for event matching against trigger configuration. */ +export interface EventMatchContext { + webhook: Record + workflow: Record + body: unknown + request: NextRequest + requestId: string + providerConfig: Record +} + +/** Context for event filtering and header enrichment. */ +export interface EventFilterContext { + webhook: Record + body: unknown + requestId: string + providerConfig: Record +} + +/** Context for custom input preparation during execution. */ +export interface FormatInputContext { + webhook: Record + workflow: { id: string; userId: string } + body: unknown + headers: Record + requestId: string +} + +/** Result of custom input preparation. */ +export interface FormatInputResult { + input: unknown + skip?: { message: string } +} + +/** Context for provider-specific file processing before execution. */ +export interface ProcessFilesContext { + input: Record + blocks: Record + blockId: string + workspaceId: string + workflowId: string + executionId: string + requestId: string + userId: string +} + +/** Context for creating an external webhook subscription during deployment. */ +export interface SubscriptionContext { + webhook: Record + workflow: Record + userId: string + requestId: string + request: NextRequest +} + +/** Result of creating an external webhook subscription. */ +export interface SubscriptionResult { + /** Fields to merge into providerConfig (externalId, webhookSecret, etc.) */ + providerConfigUpdates?: Record +} + +/** Context for deleting an external webhook subscription during undeployment. */ +export interface DeleteSubscriptionContext { + webhook: Record + workflow: Record + requestId: string +} + +/** Context for configuring polling after webhook creation. */ +export interface PollingConfigContext { + webhook: Record + requestId: string +} + +/** + * Strategy interface for provider-specific webhook behavior. + * Each provider implements only the methods it needs — all methods are optional. + */ +export interface WebhookProviderHandler { + /** Verify signature/auth. Return NextResponse(401/403) on failure, null on success. */ + verifyAuth?(ctx: AuthContext): Promise | NextResponse | null + + /** Handle reachability/verification probes after webhook lookup. */ + handleReachabilityTest?(body: unknown, requestId: string): NextResponse | null + + /** Format error responses (some providers need special formats). */ + formatErrorResponse?(error: string, status: number): NextResponse + + /** Return true to skip this event (filtering by event type, collection, etc.). */ + shouldSkipEvent?(ctx: EventFilterContext): boolean + + /** Return true if event matches, false or NextResponse to skip with a custom response. */ + matchEvent?(ctx: EventMatchContext): Promise | boolean | NextResponse + + /** Add provider-specific headers (idempotency keys, notification IDs, etc.). */ + enrichHeaders?(ctx: EventFilterContext, headers: Record): void + + /** Extract unique identifier for idempotency dedup. */ + extractIdempotencyId?(body: unknown): string | null + + /** Custom success response after queuing. Return null for default `{message: "Webhook processed"}`. */ + formatSuccessResponse?(providerConfig: Record): NextResponse | null + + /** Custom error response when queuing fails. Return null for default 500. */ + formatQueueErrorResponse?(): NextResponse | null + + /** Custom input preparation. Replaces the standard `formatWebhookInput` call when defined. */ + formatInput?(ctx: FormatInputContext): Promise + + /** Called when standard `formatWebhookInput` returns null. Return skip message or null to proceed. */ + handleEmptyInput?(requestId: string): { message: string } | null + + /** Post-process input to handle file uploads before execution. */ + processInputFiles?(ctx: ProcessFilesContext): Promise + + /** Create an external webhook subscription (e.g., register with Telegram, Airtable, etc.). */ + createSubscription?(ctx: SubscriptionContext): Promise + + /** Delete an external webhook subscription during cleanup. Errors should not throw. */ + deleteSubscription?(ctx: DeleteSubscriptionContext): Promise + + /** Configure polling after webhook creation (gmail, outlook, rss, imap). */ + configurePolling?(ctx: PollingConfigContext): Promise + + /** Handle verification challenges before webhook lookup (Slack url_verification, WhatsApp hub.verify_token, Teams validationToken). */ + handleChallenge?( + body: unknown, + request: NextRequest, + requestId: string, + path: string + ): Promise | NextResponse | null +} diff --git a/apps/sim/lib/webhooks/providers/utils.ts b/apps/sim/lib/webhooks/providers/utils.ts new file mode 100644 index 00000000000..f2a56047081 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/utils.ts @@ -0,0 +1,102 @@ +import type { Logger } from '@sim/logger' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProviderAuth') + +interface HmacVerifierOptions { + configKey: string + headerName: string + validateFn: (secret: string, signature: string, rawBody: string) => boolean | Promise + providerLabel: string +} + +/** + * Factory that creates a `verifyAuth` implementation for HMAC-signature-based providers. + * Covers the common pattern: get secret → check header → validate signature → return 401 or null. + */ +export function createHmacVerifier({ + configKey, + headerName, + validateFn, + providerLabel, +}: HmacVerifierOptions) { + return async ({ + request, + rawBody, + requestId, + providerConfig, + }: AuthContext): Promise => { + const secret = providerConfig[configKey] as string | undefined + if (!secret) { + return null + } + + const signature = request.headers.get(headerName) + if (!signature) { + logger.warn(`[${requestId}] ${providerLabel} webhook missing signature header`) + return new NextResponse(`Unauthorized - Missing ${providerLabel} signature`, { status: 401 }) + } + + const isValid = await validateFn(secret, signature, rawBody) + if (!isValid) { + logger.warn(`[${requestId}] ${providerLabel} signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + }) + return new NextResponse(`Unauthorized - Invalid ${providerLabel} signature`, { status: 401 }) + } + + return null + } +} + +/** + * Verify a bearer token or custom header token using timing-safe comparison. + * Used by generic webhooks, Google Forms, and the default handler. + */ +export function verifyTokenAuth( + request: Request, + expectedToken: string, + secretHeaderName?: string +): boolean { + if (secretHeaderName) { + const headerValue = request.headers.get(secretHeaderName.toLowerCase()) + return !!headerValue && safeCompare(headerValue, expectedToken) + } + + const authHeader = request.headers.get('authorization') + if (authHeader?.toLowerCase().startsWith('bearer ')) { + const token = authHeader.substring(7) + return safeCompare(token, expectedToken) + } + + return false +} + +/** + * Skip events whose `body.type` is not in the `providerConfig.eventTypes` allowlist. + * Shared by providers that use a simple event-type filter (Stripe, Grain, etc.). + */ +export function skipByEventTypes( + { webhook, body, requestId, providerConfig }: EventFilterContext, + providerLabel: string, + eventLogger: Logger +): boolean { + const eventTypes = providerConfig.eventTypes + if (!eventTypes || !Array.isArray(eventTypes) || eventTypes.length === 0) { + return false + } + + const eventType = (body as Record)?.type as string | undefined + if (eventType && !eventTypes.includes(eventType)) { + eventLogger.info( + `[${requestId}] ${providerLabel} event type '${eventType}' not in allowed list for webhook ${webhook.id as string}, skipping` + ) + return true + } + + return false +} diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts new file mode 100644 index 00000000000..4596e8381fc --- /dev/null +++ b/apps/sim/lib/webhooks/providers/webflow.ts @@ -0,0 +1,307 @@ +import { createLogger } from '@sim/logger' +import { validateAlphanumericId } from '@/lib/core/security/input-validation' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + DeleteSubscriptionContext, + EventFilterContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { getOAuthToken, refreshAccessTokenIfNeeded } from '@/app/api/auth/oauth/utils' + +const logger = createLogger('WebhookProvider:Webflow') + +export const webflowHandler: WebhookProviderHandler = { + async createSubscription({ + webhook: webhookRecord, + workflow, + userId, + requestId, + }: SubscriptionContext): Promise { + try { + const { path, providerConfig } = webhookRecord as Record + const config = (providerConfig as Record) || {} + const { siteId, triggerId, collectionId, formName, credentialId } = config as { + siteId?: string + triggerId?: string + collectionId?: string + formName?: string + credentialId?: string + } + + if (!siteId) { + logger.warn(`[${requestId}] Missing siteId for Webflow webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error('Site ID is required to create Webflow webhook') + } + + const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) + if (!siteIdValidation.isValid) { + throw new Error(siteIdValidation.error) + } + + if (!triggerId) { + logger.warn(`[${requestId}] Missing triggerId for Webflow webhook creation.`, { + webhookId: webhookRecord.id, + }) + throw new Error('Trigger type is required to create Webflow webhook') + } + + const credentialOwner = credentialId + ? await getCredentialOwner(credentialId, requestId) + : null + const accessToken = credentialId + ? credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + : await getOAuthToken(userId, 'webflow') + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Webflow access token for user ${userId}. Cannot create webhook in Webflow.` + ) + throw new Error( + 'Webflow account connection required. Please connect your Webflow account in the trigger configuration and try again.' + ) + } + + const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}` + + const triggerTypeMap: Record = { + webflow_collection_item_created: 'collection_item_created', + webflow_collection_item_changed: 'collection_item_changed', + webflow_collection_item_deleted: 'collection_item_deleted', + webflow_form_submission: 'form_submission', + } + + const webflowTriggerType = triggerTypeMap[triggerId] + if (!webflowTriggerType) { + logger.warn(`[${requestId}] Invalid triggerId for Webflow: ${triggerId}`, { + webhookId: webhookRecord.id, + }) + throw new Error(`Invalid Webflow trigger type: ${triggerId}`) + } + + const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks` + + const requestBody: Record = { + triggerType: webflowTriggerType, + url: notificationUrl, + } + + if (formName && webflowTriggerType === 'form_submission') { + requestBody.filter = { + name: formName, + } + } + + const webflowResponse = await fetch(webflowApiUrl, { + method: 'POST', + headers: { + Authorization: `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + accept: 'application/json', + }, + body: JSON.stringify(requestBody), + }) + + const responseBody = await webflowResponse.json() + + if (!webflowResponse.ok || responseBody.error) { + const errorMessage = + responseBody.message || responseBody.error || 'Unknown Webflow API error' + logger.error( + `[${requestId}] Failed to create webhook in Webflow for webhook ${webhookRecord.id}. Status: ${webflowResponse.status}`, + { message: errorMessage, response: responseBody } + ) + throw new Error(errorMessage) + } + + logger.info( + `[${requestId}] Successfully created webhook in Webflow for webhook ${webhookRecord.id}.`, + { + webflowWebhookId: responseBody.id || responseBody._id, + } + ) + + return { providerConfigUpdates: { externalId: responseBody.id || responseBody._id } } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Webflow webhook creation for webhook ${webhookRecord.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription({ + webhook: webhookRecord, + workflow, + requestId, + }: DeleteSubscriptionContext): Promise { + try { + const config = getProviderConfig(webhookRecord) + const siteId = config.siteId as string | undefined + const externalId = config.externalId as string | undefined + + if (!siteId) { + logger.warn( + `[${requestId}] Missing siteId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + if (!externalId) { + logger.warn( + `[${requestId}] Missing externalId for Webflow webhook deletion ${webhookRecord.id}, skipping cleanup` + ) + return + } + + const siteIdValidation = validateAlphanumericId(siteId, 'siteId', 100) + if (!siteIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Webflow site ID format, skipping deletion`, { + webhookId: webhookRecord.id, + siteId: siteId.substring(0, 30), + }) + return + } + + const webhookIdValidation = validateAlphanumericId(externalId, 'webhookId', 100) + if (!webhookIdValidation.isValid) { + logger.warn(`[${requestId}] Invalid Webflow webhook ID format, skipping deletion`, { + webhookId: webhookRecord.id, + externalId: externalId.substring(0, 30), + }) + return + } + + const credentialId = config.credentialId as string | undefined + if (!credentialId) { + logger.warn( + `[${requestId}] Missing credentialId for Webflow webhook deletion ${webhookRecord.id}` + ) + return + } + + const credentialOwner = await getCredentialOwner(credentialId, requestId) + const accessToken = credentialOwner + ? await refreshAccessTokenIfNeeded( + credentialOwner.accountId, + credentialOwner.userId, + requestId + ) + : null + if (!accessToken) { + logger.warn( + `[${requestId}] Could not retrieve Webflow access token. Cannot delete webhook.`, + { webhookId: webhookRecord.id } + ) + return + } + + const webflowApiUrl = `https://api.webflow.com/v2/sites/${siteId}/webhooks/${externalId}` + + const webflowResponse = await fetch(webflowApiUrl, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${accessToken}`, + accept: 'application/json', + }, + }) + + if (!webflowResponse.ok && webflowResponse.status !== 404) { + const responseBody = await webflowResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Webflow webhook (non-fatal): ${webflowResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Webflow webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Webflow webhook (non-fatal)`, error) + } + }, + + async formatInput({ body, webhook }: FormatInputContext): Promise { + const b = body as Record + const providerConfig = (webhook.providerConfig as Record) || {} + const triggerId = providerConfig.triggerId as string | undefined + if (triggerId === 'webflow_form_submission') { + return { + input: { + siteId: b?.siteId || '', + formId: b?.formId || '', + name: b?.name || '', + id: b?.id || '', + submittedAt: b?.submittedAt || '', + data: b?.data || {}, + schema: b?.schema || {}, + formElementId: b?.formElementId || '', + }, + } + } + const { _cid, _id, ...itemFields } = b || ({} as Record) + return { + input: { + siteId: b?.siteId || '', + collectionId: (_cid || b?.collectionId || '') as string, + payload: { + id: (_id || '') as string, + cmsLocaleId: (itemFields as Record)?.cmsLocaleId || '', + lastPublished: + (itemFields as Record)?.lastPublished || + (itemFields as Record)?.['last-published'] || + '', + lastUpdated: + (itemFields as Record)?.lastUpdated || + (itemFields as Record)?.['last-updated'] || + '', + createdOn: + (itemFields as Record)?.createdOn || + (itemFields as Record)?.['created-on'] || + '', + isArchived: + (itemFields as Record)?.isArchived || + (itemFields as Record)?._archived || + false, + isDraft: + (itemFields as Record)?.isDraft || + (itemFields as Record)?._draft || + false, + fieldData: itemFields, + }, + }, + } + }, + + shouldSkipEvent({ webhook, body, requestId, providerConfig }: EventFilterContext) { + const configuredCollectionId = providerConfig.collectionId as string | undefined + if (configuredCollectionId) { + const obj = body as Record + const payload = obj.payload as Record | undefined + const payloadCollectionId = (payload?.collectionId ?? obj.collectionId) as string | undefined + + if (payloadCollectionId && payloadCollectionId !== configuredCollectionId) { + logger.info( + `[${requestId}] Webflow collection '${payloadCollectionId}' doesn't match configured collection '${configuredCollectionId}' for webhook ${webhook.id as string}, skipping` + ) + return true + } + } + return false + }, +} diff --git a/apps/sim/lib/webhooks/providers/whatsapp.ts b/apps/sim/lib/webhooks/providers/whatsapp.ts new file mode 100644 index 00000000000..5f0116b2a1b --- /dev/null +++ b/apps/sim/lib/webhooks/providers/whatsapp.ts @@ -0,0 +1,118 @@ +import { db, workflowDeploymentVersion } from '@sim/db' +import { webhook } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull, or } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:WhatsApp') + +/** + * Handle WhatsApp verification requests + */ +export async function handleWhatsAppVerification( + requestId: string, + path: string, + mode: string | null, + token: string | null, + challenge: string | null +): Promise { + if (mode && token && challenge) { + logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`) + + if (mode !== 'subscribe') { + logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`) + return new NextResponse('Invalid mode', { status: 400 }) + } + + const webhooks = await db + .select({ webhook }) + .from(webhook) + .leftJoin( + workflowDeploymentVersion, + and( + eq(workflowDeploymentVersion.workflowId, webhook.workflowId), + eq(workflowDeploymentVersion.isActive, true) + ) + ) + .where( + and( + eq(webhook.provider, 'whatsapp'), + eq(webhook.isActive, true), + or( + eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), + and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) + ) + ) + ) + + for (const row of webhooks) { + const wh = row.webhook + const providerConfig = (wh.providerConfig as Record) || {} + const verificationToken = providerConfig.verificationToken + + if (!verificationToken) { + continue + } + + if (token === verificationToken) { + logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`) + return new NextResponse(challenge, { + status: 200, + headers: { + 'Content-Type': 'text/plain', + }, + }) + } + } + + logger.warn(`[${requestId}] No matching WhatsApp verification token found`) + return new NextResponse('Verification failed', { status: 403 }) + } + + return null +} + +export const whatsappHandler: WebhookProviderHandler = { + async handleChallenge(_body: unknown, request: NextRequest, requestId: string, path: string) { + const url = new URL(request.url) + const mode = url.searchParams.get('hub.mode') + const token = url.searchParams.get('hub.verify_token') + const challenge = url.searchParams.get('hub.challenge') + return handleWhatsAppVerification(requestId, path, mode, token, challenge) + }, + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + const entry = b?.entry as Array> | undefined + const changes = entry?.[0]?.changes as Array> | undefined + const data = changes?.[0]?.value as Record | undefined + const messages = (data?.messages as Array>) || [] + + if (messages.length > 0) { + const message = messages[0] + const metadata = data?.metadata as Record | undefined + const text = message.text as Record | undefined + return { + input: { + messageId: message.id, + from: message.from, + phoneNumberId: metadata?.phone_number_id, + text: text?.body, + timestamp: message.timestamp, + raw: JSON.stringify(message), + }, + } + } + return { input: null } + }, + + handleEmptyInput(requestId: string) { + logger.info(`[${requestId}] No messages in WhatsApp payload, skipping execution`) + return { message: 'No messages in WhatsApp payload' } + }, +} diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 49c29227ff4..4e0d3d168be 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1,2214 +1,14 @@ -import crypto from 'crypto' import { db, workflowDeploymentVersion } from '@sim/db' -import { account, webhook, workflow } from '@sim/db/schema' +import { webhook, workflow } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' -import { safeCompare } from '@/lib/core/security/encryption' -import { - type SecureFetchResponse, - secureFetchWithPinnedIP, - validateUrlWithDNS, -} from '@/lib/core/security/input-validation.server' -import { sanitizeUrlForLog } from '@/lib/core/utils/logging' import { generateShortId } from '@/lib/core/utils/uuid' import type { DbOrTx } from '@/lib/db/types' import { getProviderIdFromServiceId } from '@/lib/oauth' import { cleanupExternalWebhook } from '@/lib/webhooks/provider-subscriptions' -import { - getCredentialsForCredentialSet, - refreshAccessTokenIfNeeded, - resolveOAuthAccountId, -} from '@/app/api/auth/oauth/utils' +import { getCredentialsForCredentialSet } from '@/app/api/auth/oauth/utils' import { isPollingWebhookProvider } from '@/triggers/constants' -const logger = createLogger('WebhookUtils') - -/** - * Handle WhatsApp verification requests - */ -export async function handleWhatsAppVerification( - requestId: string, - path: string, - mode: string | null, - token: string | null, - challenge: string | null -): Promise { - if (mode && token && challenge) { - logger.info(`[${requestId}] WhatsApp verification request received for path: ${path}`) - - if (mode !== 'subscribe') { - logger.warn(`[${requestId}] Invalid WhatsApp verification mode: ${mode}`) - return new NextResponse('Invalid mode', { status: 400 }) - } - - const webhooks = await db - .select({ webhook }) - .from(webhook) - .leftJoin( - workflowDeploymentVersion, - and( - eq(workflowDeploymentVersion.workflowId, webhook.workflowId), - eq(workflowDeploymentVersion.isActive, true) - ) - ) - .where( - and( - eq(webhook.provider, 'whatsapp'), - eq(webhook.isActive, true), - or( - eq(webhook.deploymentVersionId, workflowDeploymentVersion.id), - and(isNull(workflowDeploymentVersion.id), isNull(webhook.deploymentVersionId)) - ) - ) - ) - - for (const row of webhooks) { - const wh = row.webhook - const providerConfig = (wh.providerConfig as Record) || {} - const verificationToken = providerConfig.verificationToken - - if (!verificationToken) { - continue - } - - if (token === verificationToken) { - logger.info(`[${requestId}] WhatsApp verification successful for webhook ${wh.id}`) - return new NextResponse(challenge, { - status: 200, - headers: { - 'Content-Type': 'text/plain', - }, - }) - } - } - - logger.warn(`[${requestId}] No matching WhatsApp verification token found`) - return new NextResponse('Verification failed', { status: 403 }) - } - - return null -} - -/** - * Handle Slack verification challenges - */ -export function handleSlackChallenge(body: any): NextResponse | null { - if (body.type === 'url_verification' && body.challenge) { - return NextResponse.json({ challenge: body.challenge }) - } - - return null -} - -/** - * Fetches a URL with DNS pinning to prevent DNS rebinding attacks - * @param url - The URL to fetch - * @param accessToken - Authorization token (optional for pre-signed URLs) - * @param requestId - Request ID for logging - * @returns The fetch Response or null if validation fails - */ -async function fetchWithDNSPinning( - url: string, - accessToken: string, - requestId: string -): Promise { - try { - const urlValidation = await validateUrlWithDNS(url, 'contentUrl') - if (!urlValidation.isValid) { - logger.warn(`[${requestId}] Invalid content URL: ${urlValidation.error}`, { - url, - }) - return null - } - - const headers: Record = {} - - if (accessToken) { - headers.Authorization = `Bearer ${accessToken}` - } - - const response = await secureFetchWithPinnedIP(url, urlValidation.resolvedIP!, { - headers, - }) - - return response - } catch (error) { - logger.error(`[${requestId}] Error fetching URL with DNS pinning`, { - error: error instanceof Error ? error.message : String(error), - url: sanitizeUrlForLog(url), - }) - return null - } -} - -/** - * Format Microsoft Teams Graph change notification - */ -async function formatTeamsGraphNotification( - body: any, - foundWebhook: any, - foundWorkflow: any, - request: NextRequest -): Promise { - const notification = body.value?.[0] - if (!notification) { - logger.warn('Received empty Teams notification body') - return null - } - const changeType = notification.changeType || 'created' - const resource = notification.resource || '' - const subscriptionId = notification.subscriptionId || '' - - let chatId: string | null = null - let messageId: string | null = null - - const fullMatch = resource.match(/chats\/([^/]+)\/messages\/([^/]+)/) - if (fullMatch) { - chatId = fullMatch[1] - messageId = fullMatch[2] - } - - if (!chatId || !messageId) { - const quotedMatch = resource.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) - if (quotedMatch) { - chatId = quotedMatch[1] - messageId = quotedMatch[2] - } - } - - if (!chatId || !messageId) { - const collectionMatch = resource.match(/chats\/([^/]+)\/messages$/) - const rdId = body?.value?.[0]?.resourceData?.id - if (collectionMatch && rdId) { - chatId = collectionMatch[1] - messageId = rdId - } - } - - if ((!chatId || !messageId) && body?.value?.[0]?.resourceData?.['@odata.id']) { - const odataId = String(body.value[0].resourceData['@odata.id']) - const odataMatch = odataId.match(/chats\('([^']+)'\)\/messages\('([^']+)'\)/) - if (odataMatch) { - chatId = odataMatch[1] - messageId = odataMatch[2] - } - } - - if (!chatId || !messageId) { - logger.warn('Could not resolve chatId/messageId from Teams notification', { - resource, - hasResourceDataId: Boolean(body?.value?.[0]?.resourceData?.id), - valueLength: Array.isArray(body?.value) ? body.value.length : 0, - keys: Object.keys(body || {}), - }) - return { - from: null, - message: { raw: body }, - activity: body, - conversation: null, - } - } - const resolvedChatId = chatId as string - const resolvedMessageId = messageId as string - const providerConfig = (foundWebhook?.providerConfig as Record) || {} - const credentialId = providerConfig.credentialId - const includeAttachments = providerConfig.includeAttachments !== false - - let message: any = null - const rawAttachments: Array<{ name: string; data: Buffer; contentType: string; size: number }> = - [] - let accessToken: string | null = null - - if (!credentialId) { - logger.error('Missing credentialId for Teams chat subscription', { - chatId: resolvedChatId, - messageId: resolvedMessageId, - webhookId: foundWebhook?.id, - blockId: foundWebhook?.blockId, - providerConfig, - }) - } else { - try { - const resolved = await resolveOAuthAccountId(credentialId) - if (!resolved) { - logger.error('Teams credential could not be resolved', { credentialId }) - } else { - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolved.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error('Teams credential not found', { credentialId, chatId: resolvedChatId }) - } else { - const effectiveUserId = rows[0].userId - accessToken = await refreshAccessTokenIfNeeded( - resolved.accountId, - effectiveUserId, - 'teams-graph-notification' - ) - } - } - - if (accessToken) { - const msgUrl = `https://graph.microsoft.com/v1.0/chats/${encodeURIComponent(resolvedChatId)}/messages/${encodeURIComponent(resolvedMessageId)}` - const res = await fetch(msgUrl, { headers: { Authorization: `Bearer ${accessToken}` } }) - if (res.ok) { - message = await res.json() - - if (includeAttachments && message?.attachments?.length > 0) { - const attachments = Array.isArray(message?.attachments) ? message.attachments : [] - for (const att of attachments) { - try { - const contentUrl = - typeof att?.contentUrl === 'string' ? (att.contentUrl as string) : undefined - const contentTypeHint = - typeof att?.contentType === 'string' ? (att.contentType as string) : undefined - let attachmentName = (att?.name as string) || 'teams-attachment' - - if (!contentUrl) continue - - let buffer: Buffer | null = null - let mimeType = 'application/octet-stream' - - if (contentUrl.includes('sharepoint.com') || contentUrl.includes('onedrive')) { - try { - const directRes = await fetchWithDNSPinning( - contentUrl, - accessToken, - 'teams-attachment' - ) - - if (directRes?.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - directRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else if (directRes) { - const encodedUrl = Buffer.from(contentUrl) - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - - const graphUrl = `https://graph.microsoft.com/v1.0/shares/u!${encodedUrl}/driveItem/content` - const graphRes = await fetch(graphUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - redirect: 'follow', - }) - - if (graphRes.ok) { - const arrayBuffer = await graphRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - graphRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else { - continue - } - } - } catch { - continue - } - } else if ( - contentUrl.includes('1drv.ms') || - contentUrl.includes('onedrive.live.com') || - contentUrl.includes('onedrive.com') || - contentUrl.includes('my.microsoftpersonalcontent.com') - ) { - try { - let shareToken: string | null = null - - if (contentUrl.includes('1drv.ms')) { - const urlParts = contentUrl.split('/').pop() - if (urlParts) shareToken = urlParts - } else if (contentUrl.includes('resid=')) { - const urlParams = new URL(contentUrl).searchParams - const resId = urlParams.get('resid') - if (resId) shareToken = resId - } - - if (!shareToken) { - const base64Url = Buffer.from(contentUrl, 'utf-8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - shareToken = `u!${base64Url}` - } else if (!shareToken.startsWith('u!')) { - const base64Url = Buffer.from(shareToken, 'utf-8') - .toString('base64') - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') - shareToken = `u!${base64Url}` - } - - const metadataUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem` - const metadataRes = await fetch(metadataUrl, { - headers: { - Authorization: `Bearer ${accessToken}`, - Accept: 'application/json', - }, - }) - - if (!metadataRes.ok) { - const directUrl = `https://graph.microsoft.com/v1.0/shares/${shareToken}/driveItem/content` - const directRes = await fetch(directUrl, { - headers: { Authorization: `Bearer ${accessToken}` }, - redirect: 'follow', - }) - - if (directRes.ok) { - const arrayBuffer = await directRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - directRes.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } else { - continue - } - } else { - const metadata = await metadataRes.json() - const downloadUrl = metadata['@microsoft.graph.downloadUrl'] - - if (downloadUrl) { - const downloadRes = await fetchWithDNSPinning( - downloadUrl, - '', // downloadUrl is a pre-signed URL, no auth needed - 'teams-onedrive-download' - ) - - if (downloadRes?.ok) { - const arrayBuffer = await downloadRes.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - downloadRes.headers.get('content-type') || - metadata.file?.mimeType || - contentTypeHint || - 'application/octet-stream' - - if (metadata.name && metadata.name !== attachmentName) { - attachmentName = metadata.name - } - } else { - continue - } - } else { - continue - } - } - } catch { - continue - } - } else { - try { - const ares = await fetchWithDNSPinning( - contentUrl, - accessToken, - 'teams-attachment-generic' - ) - if (ares?.ok) { - const arrayBuffer = await ares.arrayBuffer() - buffer = Buffer.from(arrayBuffer) - mimeType = - ares.headers.get('content-type') || - contentTypeHint || - 'application/octet-stream' - } - } catch { - continue - } - } - - if (!buffer) continue - - const size = buffer.length - - // Store raw attachment (will be uploaded to execution storage later) - rawAttachments.push({ - name: attachmentName, - data: buffer, - contentType: mimeType, - size, - }) - } catch {} - } - } - } - } - } catch (error) { - logger.error('Failed to fetch Teams message', { - error, - chatId: resolvedChatId, - messageId: resolvedMessageId, - }) - } - } - - if (!message) { - logger.warn('No message data available for Teams notification', { - chatId: resolvedChatId, - messageId: resolvedMessageId, - hasCredential: !!credentialId, - }) - return { - message_id: resolvedMessageId, - chat_id: resolvedChatId, - from_name: '', - text: '', - created_at: '', - attachments: [], - } - } - - const messageText = message.body?.content || '' - const from = message.from?.user || {} - const createdAt = message.createdDateTime || '' - - return { - message_id: resolvedMessageId, - chat_id: resolvedChatId, - from_name: from.displayName || '', - text: messageText, - created_at: createdAt, - attachments: rawAttachments, - } -} - -export async function validateTwilioSignature( - authToken: string, - signature: string, - url: string, - params: Record -): Promise { - try { - if (!authToken || !signature || !url) { - logger.warn('Twilio signature validation missing required fields', { - hasAuthToken: !!authToken, - hasSignature: !!signature, - hasUrl: !!url, - }) - return false - } - - const sortedKeys = Object.keys(params).sort() - let data = url - for (const key of sortedKeys) { - data += key + params[key] - } - - logger.debug('Twilio signature validation string built', { - url, - sortedKeys, - dataLength: data.length, - }) - - const encoder = new TextEncoder() - const key = await crypto.subtle.importKey( - 'raw', - encoder.encode(authToken), - { name: 'HMAC', hash: 'SHA-1' }, - false, - ['sign'] - ) - - const signatureBytes = await crypto.subtle.sign('HMAC', key, encoder.encode(data)) - - const signatureArray = Array.from(new Uint8Array(signatureBytes)) - const signatureBase64 = btoa(String.fromCharCode(...signatureArray)) - - logger.debug('Twilio signature comparison', { - computedSignature: `${signatureBase64.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: signatureBase64.length, - providedLength: signature.length, - match: signatureBase64 === signature, - }) - - return safeCompare(signatureBase64, signature) - } catch (error) { - logger.error('Error validating Twilio signature:', error) - return false - } -} - -const SLACK_MAX_FILE_SIZE = 50 * 1024 * 1024 // 50 MB -const SLACK_MAX_FILES = 15 - -/** - * Resolves the full file object from the Slack API when the event payload - * only contains a partial file (e.g. missing url_private due to file_access restrictions). - * @see https://docs.slack.dev/reference/methods/files.info - */ -async function resolveSlackFileInfo( - fileId: string, - botToken: string -): Promise<{ url_private?: string; name?: string; mimetype?: string; size?: number } | null> { - try { - const response = await fetch( - `https://slack.com/api/files.info?file=${encodeURIComponent(fileId)}`, - { - headers: { Authorization: `Bearer ${botToken}` }, - } - ) - - const data = (await response.json()) as { - ok: boolean - error?: string - file?: Record - } - - if (!data.ok || !data.file) { - logger.warn('Slack files.info failed', { fileId, error: data.error }) - return null - } - - return { - url_private: data.file.url_private, - name: data.file.name, - mimetype: data.file.mimetype, - size: data.file.size, - } - } catch (error) { - logger.error('Error calling Slack files.info', { - fileId, - error: error instanceof Error ? error.message : String(error), - }) - return null - } -} - -/** - * Downloads file attachments from Slack using the bot token. - * Returns files in the format expected by WebhookAttachmentProcessor: - * { name, data (base64 string), mimeType, size } - * - * When the event payload contains partial file objects (missing url_private), - * falls back to the Slack files.info API to resolve the full file metadata. - * - * Security: - * - Uses validateUrlWithDNS + secureFetchWithPinnedIP to prevent SSRF - * - Enforces per-file size limit and max file count - */ -async function downloadSlackFiles( - rawFiles: any[], - botToken: string -): Promise> { - const filesToProcess = rawFiles.slice(0, SLACK_MAX_FILES) - const downloaded: Array<{ name: string; data: string; mimeType: string; size: number }> = [] - - for (const file of filesToProcess) { - let urlPrivate = file.url_private as string | undefined - let fileName = file.name as string | undefined - let fileMimeType = file.mimetype as string | undefined - let fileSize = file.size as number | undefined - - // If url_private is missing, resolve via files.info API - if (!urlPrivate && file.id) { - const resolved = await resolveSlackFileInfo(file.id, botToken) - if (resolved?.url_private) { - urlPrivate = resolved.url_private - fileName = fileName || resolved.name - fileMimeType = fileMimeType || resolved.mimetype - fileSize = fileSize ?? resolved.size - } - } - - if (!urlPrivate) { - logger.warn('Slack file has no url_private and could not be resolved, skipping', { - fileId: file.id, - }) - continue - } - - // Skip files that exceed the size limit - const reportedSize = Number(fileSize) || 0 - if (reportedSize > SLACK_MAX_FILE_SIZE) { - logger.warn('Slack file exceeds size limit, skipping', { - fileId: file.id, - size: reportedSize, - limit: SLACK_MAX_FILE_SIZE, - }) - continue - } - - try { - const urlValidation = await validateUrlWithDNS(urlPrivate, 'url_private') - if (!urlValidation.isValid) { - logger.warn('Slack file url_private failed DNS validation, skipping', { - fileId: file.id, - error: urlValidation.error, - }) - continue - } - - const response = await secureFetchWithPinnedIP(urlPrivate, urlValidation.resolvedIP!, { - headers: { Authorization: `Bearer ${botToken}` }, - }) - - if (!response.ok) { - logger.warn('Failed to download Slack file, skipping', { - fileId: file.id, - status: response.status, - }) - continue - } - - const arrayBuffer = await response.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - - // Verify the actual downloaded size doesn't exceed our limit - if (buffer.length > SLACK_MAX_FILE_SIZE) { - logger.warn('Downloaded Slack file exceeds size limit, skipping', { - fileId: file.id, - actualSize: buffer.length, - limit: SLACK_MAX_FILE_SIZE, - }) - continue - } - - downloaded.push({ - name: fileName || 'download', - data: buffer.toString('base64'), - mimeType: fileMimeType || 'application/octet-stream', - size: buffer.length, - }) - } catch (error) { - logger.error('Error downloading Slack file, skipping', { - fileId: file.id, - error: error instanceof Error ? error.message : String(error), - }) - } - } - - return downloaded -} - -const SLACK_REACTION_EVENTS = new Set(['reaction_added', 'reaction_removed']) - -/** - * Fetches the text of a reacted-to message from Slack using the reactions.get API. - * Unlike conversations.history, reactions.get works for both top-level messages and - * thread replies, since it looks up the item directly by channel + timestamp. - * Requires the bot token to have the reactions:read scope. - */ -async function fetchSlackMessageText( - channel: string, - messageTs: string, - botToken: string -): Promise { - try { - const params = new URLSearchParams({ - channel, - timestamp: messageTs, - }) - const response = await fetch(`https://slack.com/api/reactions.get?${params}`, { - headers: { Authorization: `Bearer ${botToken}` }, - }) - - const data = (await response.json()) as { - ok: boolean - error?: string - type?: string - message?: { text?: string } - } - - if (!data.ok) { - logger.warn('Slack reactions.get failed — message text unavailable', { - channel, - messageTs, - error: data.error, - }) - return '' - } - - return data.message?.text ?? '' - } catch (error) { - logger.warn('Error fetching Slack message text', { - channel, - messageTs, - error: error instanceof Error ? error.message : String(error), - }) - return '' - } -} - -/** - * Format webhook input based on provider - */ -export async function formatWebhookInput( - foundWebhook: any, - foundWorkflow: any, - body: any, - request: NextRequest -): Promise { - if (foundWebhook.provider === 'whatsapp') { - const data = body?.entry?.[0]?.changes?.[0]?.value - const messages = data?.messages || [] - - if (messages.length > 0) { - const message = messages[0] - return { - messageId: message.id, - from: message.from, - phoneNumberId: data.metadata?.phone_number_id, - text: message.text?.body, - timestamp: message.timestamp, - raw: JSON.stringify(message), - } - } - return null - } - - if (foundWebhook.provider === 'telegram') { - const rawMessage = - body?.message || body?.edited_message || body?.channel_post || body?.edited_channel_post - - const updateType = body.message - ? 'message' - : body.edited_message - ? 'edited_message' - : body.channel_post - ? 'channel_post' - : body.edited_channel_post - ? 'edited_channel_post' - : 'unknown' - - if (rawMessage) { - const messageType = rawMessage.photo - ? 'photo' - : rawMessage.document - ? 'document' - : rawMessage.audio - ? 'audio' - : rawMessage.video - ? 'video' - : rawMessage.voice - ? 'voice' - : rawMessage.sticker - ? 'sticker' - : rawMessage.location - ? 'location' - : rawMessage.contact - ? 'contact' - : rawMessage.poll - ? 'poll' - : 'text' - - return { - message: { - id: rawMessage.message_id, - text: rawMessage.text, - date: rawMessage.date, - messageType, - raw: rawMessage, - }, - sender: rawMessage.from - ? { - id: rawMessage.from.id, - username: rawMessage.from.username, - firstName: rawMessage.from.first_name, - lastName: rawMessage.from.last_name, - languageCode: rawMessage.from.language_code, - isBot: rawMessage.from.is_bot, - } - : null, - updateId: body.update_id, - updateType, - } - } - - logger.warn('Unknown Telegram update type', { - updateId: body.update_id, - bodyKeys: Object.keys(body || {}), - }) - - return { - updateId: body.update_id, - updateType, - } - } - - if (foundWebhook.provider === 'twilio_voice') { - return { - callSid: body.CallSid, - accountSid: body.AccountSid, - from: body.From, - to: body.To, - callStatus: body.CallStatus, - direction: body.Direction, - apiVersion: body.ApiVersion, - callerName: body.CallerName, - forwardedFrom: body.ForwardedFrom, - digits: body.Digits, - speechResult: body.SpeechResult, - recordingUrl: body.RecordingUrl, - recordingSid: body.RecordingSid, - called: body.Called, - caller: body.Caller, - toCity: body.ToCity, - toState: body.ToState, - toZip: body.ToZip, - toCountry: body.ToCountry, - fromCity: body.FromCity, - fromState: body.FromState, - fromZip: body.FromZip, - fromCountry: body.FromCountry, - calledCity: body.CalledCity, - calledState: body.CalledState, - calledZip: body.CalledZip, - calledCountry: body.CalledCountry, - callerCity: body.CallerCity, - callerState: body.CallerState, - callerZip: body.CallerZip, - callerCountry: body.CallerCountry, - callToken: body.CallToken, - raw: JSON.stringify(body), - } - } - - if (foundWebhook.provider === 'gmail') { - if (body && typeof body === 'object' && 'email' in body) { - return { - email: body.email, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'outlook') { - if (body && typeof body === 'object' && 'email' in body) { - return { - email: body.email, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'rss') { - if (body && typeof body === 'object' && 'item' in body) { - return { - title: body.title, - link: body.link, - pubDate: body.pubDate, - item: body.item, - feed: body.feed, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'imap') { - if (body && typeof body === 'object' && 'email' in body) { - return { - messageId: body.messageId, - subject: body.subject, - from: body.from, - to: body.to, - cc: body.cc, - date: body.date, - bodyText: body.bodyText, - bodyHtml: body.bodyHtml, - mailbox: body.mailbox, - hasAttachments: body.hasAttachments, - attachments: body.attachments, - email: body.email, - timestamp: body.timestamp, - } - } - return body - } - - if (foundWebhook.provider === 'hubspot') { - const events = Array.isArray(body) ? body : [body] - const event = events[0] - - if (!event) { - logger.warn('HubSpot webhook received with empty payload') - return null - } - - logger.info('Formatting HubSpot webhook input', { - subscriptionType: event.subscriptionType, - objectId: event.objectId, - portalId: event.portalId, - }) - - return { - payload: body, - provider: 'hubspot', - providerConfig: foundWebhook.providerConfig, - } - } - - if (foundWebhook.provider === 'microsoft-teams') { - if (body?.value && Array.isArray(body.value) && body.value.length > 0) { - return await formatTeamsGraphNotification(body, foundWebhook, foundWorkflow, request) - } - - const messageText = body?.text || '' - const messageId = body?.id || '' - const timestamp = body?.timestamp || body?.localTimestamp || '' - const from = body?.from || {} - const conversation = body?.conversation || {} - - const messageObj = { - raw: { - attachments: body?.attachments || [], - channelData: body?.channelData || {}, - conversation: body?.conversation || {}, - text: messageText, - messageType: body?.type || 'message', - channelId: body?.channelId || '', - timestamp, - }, - } - - const fromObj = { - id: from.id || '', - name: from.name || '', - aadObjectId: from.aadObjectId || '', - } - - const conversationObj = { - id: conversation.id || '', - name: conversation.name || '', - isGroup: conversation.isGroup || false, - tenantId: conversation.tenantId || '', - aadObjectId: conversation.aadObjectId || '', - conversationType: conversation.conversationType || '', - } - - const activityObj = body || {} - - return { - from: fromObj, - message: messageObj, - activity: activityObj, - conversation: conversationObj, - } - } - - if (foundWebhook.provider === 'slack') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const botToken = providerConfig.botToken as string | undefined - const includeFiles = Boolean(providerConfig.includeFiles) - - const rawEvent = body?.event - - if (!rawEvent) { - logger.warn('Unknown Slack event type', { - type: body?.type, - hasEvent: false, - bodyKeys: Object.keys(body || {}), - }) - } - - const eventType: string = rawEvent?.type || body?.type || 'unknown' - const isReactionEvent = SLACK_REACTION_EVENTS.has(eventType) - - // Reaction events nest channel/ts inside event.item - const channel: string = isReactionEvent - ? rawEvent?.item?.channel || '' - : rawEvent?.channel || '' - const messageTs: string = isReactionEvent - ? rawEvent?.item?.ts || '' - : rawEvent?.ts || rawEvent?.event_ts || '' - - // For reaction events, attempt to fetch the original message text - let text: string = rawEvent?.text || '' - if (isReactionEvent && channel && messageTs && botToken) { - text = await fetchSlackMessageText(channel, messageTs, botToken) - } - - const rawFiles: any[] = rawEvent?.files ?? [] - const hasFiles = rawFiles.length > 0 - - let files: any[] = [] - if (hasFiles && includeFiles && botToken) { - files = await downloadSlackFiles(rawFiles, botToken) - } else if (hasFiles && includeFiles && !botToken) { - logger.warn('Slack message has files and includeFiles is enabled, but no bot token provided') - } - - return { - event: { - event_type: eventType, - channel, - channel_name: '', - user: rawEvent?.user || '', - user_name: '', - text, - timestamp: messageTs, - thread_ts: rawEvent?.thread_ts || '', - team_id: body?.team_id || rawEvent?.team || '', - event_id: body?.event_id || '', - reaction: rawEvent?.reaction || '', - item_user: rawEvent?.item_user || '', - hasFiles, - files, - }, - } - } - - if (foundWebhook.provider === 'webflow') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - // Form submission trigger - if (triggerId === 'webflow_form_submission') { - return { - siteId: body?.siteId || '', - formId: body?.formId || '', - name: body?.name || '', - id: body?.id || '', - submittedAt: body?.submittedAt || '', - data: body?.data || {}, - schema: body?.schema || {}, - formElementId: body?.formElementId || '', - } - } - - // Collection item triggers (created, changed, deleted) - // Webflow uses _cid for collection ID and _id for item ID - const { _cid, _id, ...itemFields } = body || {} - return { - siteId: body?.siteId || '', - collectionId: _cid || body?.collectionId || '', - payload: { - id: _id || '', - cmsLocaleId: itemFields?.cmsLocaleId || '', - lastPublished: itemFields?.lastPublished || itemFields?.['last-published'] || '', - lastUpdated: itemFields?.lastUpdated || itemFields?.['last-updated'] || '', - createdOn: itemFields?.createdOn || itemFields?.['created-on'] || '', - isArchived: itemFields?.isArchived || itemFields?._archived || false, - isDraft: itemFields?.isDraft || itemFields?._draft || false, - fieldData: itemFields, - }, - } - } - - if (foundWebhook.provider === 'generic') { - return body - } - - if (foundWebhook.provider === 'google_forms') { - const providerConfig = (foundWebhook.providerConfig as Record) || {} - - const normalizeAnswers = (src: unknown): Record => { - if (!src || typeof src !== 'object') return {} - const out: Record = {} - for (const [k, v] of Object.entries(src as Record)) { - if (Array.isArray(v)) { - out[k] = v.length === 1 ? v[0] : v - } else { - out[k] = v as unknown - } - } - return out - } - - const responseId = body?.responseId || body?.id || '' - const createTime = body?.createTime || body?.timestamp || new Date().toISOString() - const lastSubmittedTime = body?.lastSubmittedTime || createTime - const formId = body?.formId || providerConfig.formId || '' - const includeRaw = providerConfig.includeRawPayload !== false - - return { - responseId, - createTime, - lastSubmittedTime, - formId, - answers: normalizeAnswers(body?.answers), - ...(includeRaw ? { raw: body?.raw ?? body } : {}), - } - } - - if (foundWebhook.provider === 'github') { - const eventType = request.headers.get('x-github-event') || 'unknown' - const branch = body?.ref?.replace('refs/heads/', '') || '' - - return { - ...body, - event_type: eventType, - action: body?.action || '', - branch, - } - } - - if (foundWebhook.provider === 'typeform') { - const formResponse = body?.form_response || {} - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const includeDefinition = providerConfig.includeDefinition === true - - return { - event_id: body?.event_id || '', - event_type: body?.event_type || 'form_response', - form_id: formResponse.form_id || '', - token: formResponse.token || '', - submitted_at: formResponse.submitted_at || '', - landed_at: formResponse.landed_at || '', - calculated: formResponse.calculated || {}, - variables: formResponse.variables || [], - hidden: formResponse.hidden || {}, - answers: formResponse.answers || [], - ...(includeDefinition ? { definition: formResponse.definition || {} } : {}), - ending: formResponse.ending || {}, - raw: body, - } - } - - if (foundWebhook.provider === 'linear') { - return { - action: body.action || '', - type: body.type || '', - webhookId: body.webhookId || '', - webhookTimestamp: body.webhookTimestamp || 0, - organizationId: body.organizationId || '', - createdAt: body.createdAt || '', - actor: body.actor || null, - data: body.data || null, - updatedFrom: body.updatedFrom || null, - } - } - - if (foundWebhook.provider === 'jira') { - const { extractIssueData, extractCommentData, extractWorklogData } = await import( - '@/triggers/jira/utils' - ) - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId === 'jira_issue_commented') { - return extractCommentData(body) - } - if (triggerId === 'jira_worklog_created') { - return extractWorklogData(body) - } - return extractIssueData(body) - } - - if (foundWebhook.provider === 'confluence') { - const { - extractPageData, - extractCommentData, - extractBlogData, - extractAttachmentData, - extractSpaceData, - extractLabelData, - } = await import('@/triggers/confluence/utils') - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId?.startsWith('confluence_comment_')) { - return extractCommentData(body) - } - if (triggerId?.startsWith('confluence_blog_')) { - return extractBlogData(body) - } - if (triggerId?.startsWith('confluence_attachment_')) { - return extractAttachmentData(body) - } - if (triggerId?.startsWith('confluence_space_')) { - return extractSpaceData(body) - } - if (triggerId?.startsWith('confluence_label_')) { - return extractLabelData(body) - } - // Generic webhook — preserve all entity fields since event type varies - if (triggerId === 'confluence_webhook') { - return { - timestamp: body.timestamp, - userAccountId: body.userAccountId, - accountType: body.accountType, - page: body.page || null, - comment: body.comment || null, - blog: body.blog || body.blogpost || null, - attachment: body.attachment || null, - space: body.space || null, - label: body.label || null, - content: body.content || null, - } - } - // Default: page events - return extractPageData(body) - } - - if (foundWebhook.provider === 'ashby') { - return { - ...(body.data || {}), - action: body.action, - data: body.data || {}, - } - } - - if (foundWebhook.provider === 'stripe') { - return body - } - - if (foundWebhook.provider === 'calendly') { - return { - event: body.event, - created_at: body.created_at, - created_by: body.created_by, - payload: body.payload, - } - } - - if (foundWebhook.provider === 'circleback') { - return { - id: body.id, - name: body.name, - createdAt: body.createdAt, - duration: body.duration, - url: body.url, - recordingUrl: body.recordingUrl, - tags: body.tags || [], - icalUid: body.icalUid, - attendees: body.attendees || [], - notes: body.notes || '', - actionItems: body.actionItems || [], - transcript: body.transcript || [], - insights: body.insights || {}, - meeting: body, - } - } - - if (foundWebhook.provider === 'grain') { - return { - type: body.type, - user_id: body.user_id, - data: body.data || {}, - } - } - - if (foundWebhook.provider === 'fireflies') { - return { - meetingId: body.meetingId || '', - eventType: body.eventType || 'Transcription completed', - clientReferenceId: body.clientReferenceId || '', - } - } - - if (foundWebhook.provider === 'attio') { - const { - extractAttioRecordData, - extractAttioRecordUpdatedData, - extractAttioRecordMergedData, - extractAttioNoteData, - extractAttioTaskData, - extractAttioCommentData, - extractAttioListEntryData, - extractAttioListEntryUpdatedData, - extractAttioListData, - extractAttioWorkspaceMemberData, - extractAttioGenericData, - } = await import('@/triggers/attio/utils') - - const providerConfig = (foundWebhook.providerConfig as Record) || {} - const triggerId = providerConfig.triggerId as string | undefined - - if (triggerId === 'attio_record_updated') { - return extractAttioRecordUpdatedData(body) - } - if (triggerId === 'attio_record_merged') { - return extractAttioRecordMergedData(body) - } - if (triggerId === 'attio_record_created' || triggerId === 'attio_record_deleted') { - return extractAttioRecordData(body) - } - if (triggerId?.startsWith('attio_note_')) { - return extractAttioNoteData(body) - } - if (triggerId?.startsWith('attio_task_')) { - return extractAttioTaskData(body) - } - if (triggerId?.startsWith('attio_comment_')) { - return extractAttioCommentData(body) - } - if (triggerId === 'attio_list_entry_updated') { - return extractAttioListEntryUpdatedData(body) - } - if (triggerId === 'attio_list_entry_created' || triggerId === 'attio_list_entry_deleted') { - return extractAttioListEntryData(body) - } - if ( - triggerId === 'attio_list_created' || - triggerId === 'attio_list_updated' || - triggerId === 'attio_list_deleted' - ) { - return extractAttioListData(body) - } - if (triggerId === 'attio_workspace_member_created') { - return extractAttioWorkspaceMemberData(body) - } - return extractAttioGenericData(body) - } - - return body -} - -/** - * Validates a Microsoft Teams outgoing webhook request signature using HMAC SHA-256 - * @param hmacSecret - Microsoft Teams HMAC secret (base64 encoded) - * @param signature - Authorization header value (should start with 'HMAC ') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateMicrosoftTeamsSignature( - hmacSecret: string, - signature: string, - body: string -): boolean { - try { - if (!hmacSecret || !signature || !body) { - return false - } - - if (!signature.startsWith('HMAC ')) { - return false - } - - const providedSignature = signature.substring(5) - - const secretBytes = Buffer.from(hmacSecret, 'base64') - const bodyBytes = Buffer.from(body, 'utf8') - const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Microsoft Teams signature:', error) - return false - } -} - -/** - * Validates a Typeform webhook request signature using HMAC SHA-256 - * @param secret - Typeform webhook secret (plain text) - * @param signature - Typeform-Signature header value (should be in format 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateTypeformSignature( - secret: string, - signature: string, - body: string -): boolean { - try { - if (!secret || !signature || !body) { - return false - } - - if (!signature.startsWith('sha256=')) { - return false - } - - const providedSignature = signature.substring(7) - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Typeform signature:', error) - return false - } -} - -/** - * Validates a Linear webhook request signature using HMAC SHA-256 - * @param secret - Linear webhook secret (plain text) - * @param signature - Linear-Signature header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateLinearSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Linear signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Linear signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: signature.length, - match: computedHash === signature, - }) - - return safeCompare(computedHash, signature) - } catch (error) { - logger.error('Error validating Linear signature:', error) - return false - } -} - -/** - * Validates an Attio webhook request signature using HMAC SHA-256 - * @param secret - Attio webhook signing secret (plain text) - * @param signature - Attio-Signature header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateAttioSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Attio signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Attio signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: signature.length, - match: computedHash === signature, - }) - - return safeCompare(computedHash, signature) - } catch (error) { - logger.error('Error validating Attio signature:', error) - return false - } -} - -/** - * Validates a Circleback webhook request signature using HMAC SHA-256 - * @param secret - Circleback signing secret (plain text) - * @param signature - x-signature header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateCirclebackSignature( - secret: string, - signature: string, - body: string -): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Circleback signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Circleback signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${signature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: signature.length, - match: computedHash === signature, - }) - - return safeCompare(computedHash, signature) - } catch (error) { - logger.error('Error validating Circleback signature:', error) - return false - } -} - -/** - * Validates a Jira webhook request signature using HMAC SHA-256 - * @param secret - Jira webhook secret (plain text) - * @param signature - X-Hub-Signature header value (format: 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateJiraSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Jira signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - if (!signature.startsWith('sha256=')) { - logger.warn('Jira signature has invalid format (expected sha256=)', { - signaturePrefix: signature.substring(0, 10), - }) - return false - } - - const providedSignature = signature.substring(7) - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Jira signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Jira signature:', error) - return false - } -} - -/** - * Validates a Fireflies webhook request signature using HMAC SHA-256 - * @param secret - Fireflies webhook secret (16-32 characters) - * @param signature - x-hub-signature header value (format: 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateFirefliesSignature( - secret: string, - signature: string, - body: string -): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Fireflies signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - if (!signature.startsWith('sha256=')) { - logger.warn('Fireflies signature has invalid format (expected sha256=)', { - signaturePrefix: signature.substring(0, 10), - }) - return false - } - - const providedSignature = signature.substring(7) - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Fireflies signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Fireflies signature:', error) - return false - } -} - -/** - * Validates an Ashby webhook signature using HMAC-SHA256. - * Ashby signs payloads with the secretToken and sends the digest in the Ashby-Signature header. - * @param secretToken - The secret token configured when creating the webhook - * @param signature - Ashby-Signature header value (format: 'sha256=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateAshbySignature( - secretToken: string, - signature: string, - body: string -): boolean { - try { - if (!secretToken || !signature || !body) { - return false - } - - if (!signature.startsWith('sha256=')) { - return false - } - - const providedSignature = signature.substring(7) - const computedHash = crypto.createHmac('sha256', secretToken).update(body, 'utf8').digest('hex') - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Ashby signature:', error) - return false - } -} - -/** - * Validates a GitHub webhook request signature using HMAC SHA-256 or SHA-1 - * @param secret - GitHub webhook secret (plain text) - * @param signature - X-Hub-Signature-256 or X-Hub-Signature header value (format: 'sha256=' or 'sha1=') - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateGitHubSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('GitHub signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - let algorithm: 'sha256' | 'sha1' - let providedSignature: string - - if (signature.startsWith('sha256=')) { - algorithm = 'sha256' - providedSignature = signature.substring(7) - } else if (signature.startsWith('sha1=')) { - algorithm = 'sha1' - providedSignature = signature.substring(5) - } else { - logger.warn('GitHub signature has invalid format', { - signature: `${signature.substring(0, 10)}...`, - }) - return false - } - - const computedHash = crypto.createHmac(algorithm, secret).update(body, 'utf8').digest('hex') - - logger.debug('GitHub signature comparison', { - algorithm, - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating GitHub signature:', error) - return false - } -} - -/** - * Process webhook provider-specific verification - */ -export function verifyProviderWebhook( - foundWebhook: any, - request: NextRequest, - requestId: string -): NextResponse | null { - const authHeader = request.headers.get('authorization') - const providerConfig = (foundWebhook.providerConfig as Record) || {} - switch (foundWebhook.provider) { - case 'github': - break - case 'stripe': - break - case 'gmail': - break - case 'telegram': { - // Check User-Agent to ensure it's not blocked by middleware - const userAgent = request.headers.get('user-agent') || '' - - if (!userAgent) { - logger.warn( - `[${requestId}] Telegram webhook request has empty User-Agent header. This may be blocked by middleware.` - ) - } - - // Telegram uses IP addresses in specific ranges - const clientIp = - request.headers.get('x-forwarded-for')?.split(',')[0].trim() || - request.headers.get('x-real-ip') || - 'unknown' - - break - } - case 'microsoft-teams': - break - case 'generic': - if (providerConfig.requireAuth) { - let isAuthenticated = false - if (providerConfig.token) { - const bearerMatch = authHeader?.match(/^bearer\s+(.+)$/i) - const providedToken = bearerMatch ? bearerMatch[1] : null - if (providedToken === providerConfig.token) { - isAuthenticated = true - } - if (!isAuthenticated && providerConfig.secretHeaderName) { - const customHeaderValue = request.headers.get(providerConfig.secretHeaderName) - if (customHeaderValue === providerConfig.token) { - isAuthenticated = true - } - } - if (!isAuthenticated) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) - return new NextResponse('Unauthorized - Invalid authentication token', { status: 401 }) - } - } - } - if ( - providerConfig.allowedIps && - Array.isArray(providerConfig.allowedIps) && - providerConfig.allowedIps.length > 0 - ) { - const clientIp = - request.headers.get('x-forwarded-for')?.split(',')[0].trim() || - request.headers.get('x-real-ip') || - 'unknown' - - if (clientIp === 'unknown' || !providerConfig.allowedIps.includes(clientIp)) { - logger.warn( - `[${requestId}] Forbidden webhook access attempt - IP not allowed: ${clientIp}` - ) - return new NextResponse('Forbidden - IP not allowed', { - status: 403, - }) - } - } - break - default: - if (providerConfig.token) { - const providedToken = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null - if (!providedToken || providedToken !== providerConfig.token) { - logger.warn(`[${requestId}] Unauthorized webhook access attempt - invalid token`) - return new NextResponse('Unauthorized', { status: 401 }) - } - } - } - - return null -} - -/** - * Process Airtable payloads - */ -export async function fetchAndProcessAirtablePayloads( - webhookData: any, - workflowData: any, - requestId: string // Original request ID from the ping, used for the final execution log -) { - // Logging handles all error logging - let currentCursor: number | null = null - let mightHaveMore = true - let payloadsFetched = 0 - let apiCallCount = 0 - // Use a Map to consolidate changes per record ID - const consolidatedChangesMap = new Map() - // Capture raw payloads from Airtable for exposure to workflows - const allPayloads = [] - const localProviderConfig = { - ...((webhookData.providerConfig as Record) || {}), - } - - try { - // --- Essential IDs & Config from localProviderConfig --- - const baseId = localProviderConfig.baseId - const airtableWebhookId = localProviderConfig.externalId - - if (!baseId || !airtableWebhookId) { - logger.error( - `[${requestId}] Missing baseId or externalId in providerConfig for webhook ${webhookData.id}. Cannot fetch payloads.` - ) - return - } - - const credentialId: string | undefined = localProviderConfig.credentialId - if (!credentialId) { - logger.error( - `[${requestId}] Missing credentialId in providerConfig for Airtable webhook ${webhookData.id}.` - ) - return - } - - const resolvedAirtable = await resolveOAuthAccountId(credentialId) - if (!resolvedAirtable) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Airtable webhook` - ) - return - } - - let ownerUserId: string | null = null - try { - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedAirtable.accountId)) - .limit(1) - ownerUserId = rows.length ? rows[0].userId : null - } catch (_e) { - ownerUserId = null - } - - if (!ownerUserId) { - logger.error( - `[${requestId}] Could not resolve owner for Airtable credential ${credentialId} on webhook ${webhookData.id}` - ) - return - } - - const storedCursor = localProviderConfig.externalWebhookCursor - - if (storedCursor === undefined || storedCursor === null) { - logger.info( - `[${requestId}] No cursor found in providerConfig for webhook ${webhookData.id}, initializing...` - ) - localProviderConfig.externalWebhookCursor = null - - try { - await db - .update(webhook) - .set({ - providerConfig: { - ...localProviderConfig, - externalWebhookCursor: null, - }, - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookData.id)) - - localProviderConfig.externalWebhookCursor = null - logger.info(`[${requestId}] Successfully initialized cursor for webhook ${webhookData.id}`) - } catch (initError: any) { - logger.error(`[${requestId}] Failed to initialize cursor in DB`, { - webhookId: webhookData.id, - error: initError.message, - stack: initError.stack, - }) - } - } - - if (storedCursor && typeof storedCursor === 'number') { - currentCursor = storedCursor - } else { - currentCursor = null - } - - let accessToken: string | null = null - try { - accessToken = await refreshAccessTokenIfNeeded( - resolvedAirtable.accountId, - ownerUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to obtain valid Airtable access token via credential ${credentialId}.` - ) - throw new Error('Airtable access token not found.') - } - } catch (tokenError: any) { - logger.error( - `[${requestId}] Failed to get Airtable OAuth token for credential ${credentialId}`, - { - error: tokenError.message, - stack: tokenError.stack, - credentialId, - } - ) - return - } - - const airtableApiBase = 'https://api.airtable.com/v0' - - // --- Polling Loop --- - while (mightHaveMore) { - apiCallCount++ - // Safety break - if (apiCallCount > 10) { - mightHaveMore = false - break - } - - const apiUrl = `${airtableApiBase}/bases/${baseId}/webhooks/${airtableWebhookId}/payloads` - const queryParams = new URLSearchParams() - if (currentCursor !== null) { - queryParams.set('cursor', currentCursor.toString()) - } - const fullUrl = `${apiUrl}?${queryParams.toString()}` - - try { - const fetchStartTime = Date.now() - const response = await fetch(fullUrl, { - method: 'GET', - headers: { - Authorization: `Bearer ${accessToken}`, - 'Content-Type': 'application/json', - }, - }) - - const responseBody = await response.json() - - if (!response.ok || responseBody.error) { - const errorMessage = - responseBody.error?.message || - responseBody.error || - `Airtable API error Status ${response.status}` - logger.error( - `[${requestId}] Airtable API request to /payloads failed (Call ${apiCallCount})`, - { - webhookId: webhookData.id, - status: response.status, - error: errorMessage, - } - ) - // Error logging handled by logging session - mightHaveMore = false - break - } - - const receivedPayloads = responseBody.payloads || [] - - // --- Process and Consolidate Changes --- - if (receivedPayloads.length > 0) { - payloadsFetched += receivedPayloads.length - // Keep the raw payloads for later exposure to the workflow - for (const p of receivedPayloads) { - allPayloads.push(p) - } - let changeCount = 0 - for (const payload of receivedPayloads) { - if (payload.changedTablesById) { - for (const [tableId, tableChangesUntyped] of Object.entries( - payload.changedTablesById - )) { - const tableChanges = tableChangesUntyped as any // Assert type - - // Handle created records - if (tableChanges.createdRecordsById) { - const createdCount = Object.keys(tableChanges.createdRecordsById).length - changeCount += createdCount - - for (const [recordId, recordDataUntyped] of Object.entries( - tableChanges.createdRecordsById - )) { - const recordData = recordDataUntyped as any // Assert type - const existingChange = consolidatedChangesMap.get(recordId) - if (existingChange) { - // Record was created and possibly updated within the same batch - existingChange.changedFields = { - ...existingChange.changedFields, - ...(recordData.cellValuesByFieldId || {}), - } - // Keep changeType as 'created' if it started as created - } else { - // New creation - consolidatedChangesMap.set(recordId, { - tableId: tableId, - recordId: recordId, - changeType: 'created', - changedFields: recordData.cellValuesByFieldId || {}, - }) - } - } - } - - // Handle updated records - if (tableChanges.changedRecordsById) { - const updatedCount = Object.keys(tableChanges.changedRecordsById).length - changeCount += updatedCount - - for (const [recordId, recordDataUntyped] of Object.entries( - tableChanges.changedRecordsById - )) { - const recordData = recordDataUntyped as any // Assert type - const existingChange = consolidatedChangesMap.get(recordId) - const currentFields = recordData.current?.cellValuesByFieldId || {} - - if (existingChange) { - // Existing record was updated again - existingChange.changedFields = { - ...existingChange.changedFields, - ...currentFields, - } - // Ensure type is 'updated' if it was previously 'created' - existingChange.changeType = 'updated' - // Do not update previousFields again - } else { - // First update for this record in the batch - const newChange: AirtableChange = { - tableId: tableId, - recordId: recordId, - changeType: 'updated', - changedFields: currentFields, - } - if (recordData.previous?.cellValuesByFieldId) { - newChange.previousFields = recordData.previous.cellValuesByFieldId - } - consolidatedChangesMap.set(recordId, newChange) - } - } - } - // TODO: Handle deleted records (`destroyedRecordIds`) if needed - } - } - } - } - - const nextCursor = responseBody.cursor - mightHaveMore = responseBody.mightHaveMore || false - - if (nextCursor && typeof nextCursor === 'number' && nextCursor !== currentCursor) { - currentCursor = nextCursor - - // Follow exactly the old implementation - use awaited update instead of parallel - const updatedConfig = { - ...localProviderConfig, - externalWebhookCursor: currentCursor, - } - try { - // Force a complete object update to ensure consistency in serverless env - await db - .update(webhook) - .set({ - providerConfig: updatedConfig, // Use full object - updatedAt: new Date(), - }) - .where(eq(webhook.id, webhookData.id)) - - localProviderConfig.externalWebhookCursor = currentCursor // Update local copy too - } catch (dbError: any) { - logger.error(`[${requestId}] Failed to persist Airtable cursor to DB`, { - webhookId: webhookData.id, - cursor: currentCursor, - error: dbError.message, - }) - // Error logging handled by logging session - mightHaveMore = false - throw new Error('Failed to save Airtable cursor, stopping processing.') // Re-throw to break loop clearly - } - } else if (!nextCursor || typeof nextCursor !== 'number') { - logger.warn(`[${requestId}] Invalid or missing cursor received, stopping poll`, { - webhookId: webhookData.id, - apiCall: apiCallCount, - receivedCursor: nextCursor, - }) - mightHaveMore = false - } else if (nextCursor === currentCursor) { - mightHaveMore = false // Explicitly stop if cursor hasn't changed - } - } catch (fetchError: any) { - logger.error( - `[${requestId}] Network error calling Airtable GET /payloads (Call ${apiCallCount}) for webhook ${webhookData.id}`, - fetchError - ) - // Error logging handled by logging session - mightHaveMore = false - break - } - } - // --- End Polling Loop --- - - // Convert map values to array for final processing - const finalConsolidatedChanges = Array.from(consolidatedChangesMap.values()) - logger.info( - `[${requestId}] Consolidated ${finalConsolidatedChanges.length} Airtable changes across ${apiCallCount} API calls` - ) - - // --- Execute Workflow if we have changes (simplified - no lock check) --- - if (finalConsolidatedChanges.length > 0 || allPayloads.length > 0) { - try { - // Build input exposing raw payloads and consolidated changes - const latestPayload = allPayloads.length > 0 ? allPayloads[allPayloads.length - 1] : null - const input: any = { - // Raw Airtable payloads as received from the API - payloads: allPayloads, - latestPayload, - // Consolidated, simplified changes for convenience - airtableChanges: finalConsolidatedChanges, - // Include webhook metadata for resolver fallbacks - webhook: { - data: { - provider: 'airtable', - providerConfig: webhookData.providerConfig, - payload: latestPayload, - }, - }, - } - - // CRITICAL EXECUTION TRACE POINT - logger.info( - `[${requestId}] CRITICAL_TRACE: Beginning workflow execution with ${finalConsolidatedChanges.length} Airtable changes`, - { - workflowId: workflowData.id, - recordCount: finalConsolidatedChanges.length, - timestamp: new Date().toISOString(), - firstRecordId: finalConsolidatedChanges[0]?.recordId || 'none', - } - ) - - // Return the processed input for the trigger.dev task to handle - logger.info(`[${requestId}] CRITICAL_TRACE: Airtable changes processed, returning input`, { - workflowId: workflowData.id, - recordCount: finalConsolidatedChanges.length, - rawPayloadCount: allPayloads.length, - timestamp: new Date().toISOString(), - }) - - return input - } catch (processingError: any) { - logger.error(`[${requestId}] CRITICAL_TRACE: Error processing Airtable changes`, { - workflowId: workflowData.id, - error: processingError.message, - stack: processingError.stack, - timestamp: new Date().toISOString(), - }) - - throw processingError - } - } else { - // DEBUG: Log when no changes are found - logger.info(`[${requestId}] TRACE: No Airtable changes to process`, { - workflowId: workflowData.id, - apiCallCount, - webhookId: webhookData.id, - }) - } - } catch (error) { - // Catch any unexpected errors during the setup/polling logic itself - logger.error( - `[${requestId}] Unexpected error during asynchronous Airtable payload processing task`, - { - webhookId: webhookData.id, - workflowId: workflowData.id, - error: (error as Error).message, - } - ) - // Error logging handled by logging session - } -} - -// Define an interface for AirtableChange -export interface AirtableChange { - tableId: string - recordId: string - changeType: 'created' | 'updated' - changedFields: Record // { fieldId: newValue } - previousFields?: Record // { fieldId: previousValue } (optional) -} - /** * Result of syncing webhooks for a credential set */ @@ -2251,7 +51,7 @@ export async function syncWebhooksForCredentialSet(params: { basePath: string credentialSetId: string oauthProviderId: string - providerConfig: Record + providerConfig: Record requestId: string tx?: DbOrTx deploymentVersionId?: string @@ -2291,7 +91,6 @@ export async function syncWebhooksForCredentialSet(params: { `[${requestId}] Found ${credentials.length} credentials in set ${credentialSetId}` ) - // Get existing webhooks for this workflow+block that belong to this credential set const existingWebhooks = await dbCtx .select() .from(webhook) @@ -2310,7 +109,6 @@ export async function syncWebhooksForCredentialSet(params: { ) ) - // Filter to only webhooks belonging to this credential set const credentialSetWebhooks = existingWebhooks.filter( (wh) => wh.credentialSetId === credentialSetId ) @@ -2319,12 +117,11 @@ export async function syncWebhooksForCredentialSet(params: { `[${requestId}] Found ${credentialSetWebhooks.length} existing webhooks for credential set` ) - // Build maps for efficient lookup const existingByCredentialId = new Map() for (const wh of credentialSetWebhooks) { - const config = wh.providerConfig as Record + const config = wh.providerConfig as Record if (config?.credentialId) { - existingByCredentialId.set(config.credentialId, wh) + existingByCredentialId.set(config.credentialId as string, wh) } } @@ -2347,21 +144,18 @@ export async function syncWebhooksForCredentialSet(params: { failed: [], } - // Process each credential in the set for (const cred of credentials) { try { const existingWebhook = existingByCredentialId.get(cred.credentialId) if (existingWebhook) { - // Update existing webhook - preserve state fields - const existingConfig = existingWebhook.providerConfig as Record + const existingConfig = existingWebhook.providerConfig as Record const updatedConfig = { ...providerConfig, - basePath, // Store basePath for reliable reconstruction during membership sync + basePath, credentialId: cred.credentialId, credentialSetId: credentialSetId, - // Preserve state fields from existing config historyId: existingConfig?.historyId, lastCheckedTimestamp: existingConfig?.lastCheckedTimestamp, setupCompleted: existingConfig?.setupCompleted, @@ -2390,7 +184,6 @@ export async function syncWebhooksForCredentialSet(params: { `[${requestId}] Updated webhook ${existingWebhook.id} for credential ${cred.credentialId}` ) } else { - // Create new webhook for this credential const webhookId = generateShortId() const webhookPath = useUniquePaths ? `${basePath}-${cred.credentialId.slice(0, 8)}` @@ -2398,7 +191,7 @@ export async function syncWebhooksForCredentialSet(params: { const newConfig = { ...providerConfig, - basePath, // Store basePath for reliable reconstruction during membership sync + basePath, credentialId: cred.credentialId, credentialSetId: credentialSetId, userId: cred.userId, @@ -2411,7 +204,7 @@ export async function syncWebhooksForCredentialSet(params: { path: webhookPath, provider, providerConfig: newConfig, - credentialSetId, // Indexed column for efficient credential set queries + credentialSetId, isActive: true, ...(deploymentVersionId ? { deploymentVersionId } : {}), createdAt: new Date(), @@ -2441,7 +234,6 @@ export async function syncWebhooksForCredentialSet(params: { } } - // Delete webhooks for credentials no longer in the set for (const [credentialId, existingWebhook] of existingByCredentialId) { if (!credentialIdsInSet.has(credentialId)) { try { @@ -2489,7 +281,6 @@ export async function syncAllWebhooksForCredentialSet( const syncLogger = createLogger('CredentialSetMembershipSync') syncLogger.info(`[${requestId}] Syncing all webhooks for credential set ${credentialSetId}`) - // Find all webhooks that use this credential set using the indexed column const webhooksForSet = await dbCtx .select({ webhook }) .from(webhook) @@ -2516,12 +307,10 @@ export async function syncAllWebhooksForCredentialSet( return { workflowsUpdated: 0, totalCreated: 0, totalDeleted: 0 } } - // Group webhooks by workflow+block to find unique triggers const triggerGroups = new Map() for (const row of webhooksForSet) { const wh = row.webhook const key = `${wh.workflowId}:${wh.blockId}` - // Keep the first webhook as representative (they all have same config) if (!triggerGroups.has(key)) { triggerGroups.set(key, wh) } @@ -2541,15 +330,15 @@ export async function syncAllWebhooksForCredentialSet( continue } - const config = representativeWebhook.providerConfig as Record + const config = representativeWebhook.providerConfig as Record const oauthProviderId = getProviderIdFromServiceId(representativeWebhook.provider) const { credentialId: _cId, userId: _uId, basePath: _bp, ...baseConfig } = config - // Use stored basePath if available, otherwise fall back to blockId (for legacy webhooks) - const basePath = config.basePath || representativeWebhook.blockId || representativeWebhook.path + const basePath = + (config.basePath as string) || representativeWebhook.blockId || representativeWebhook.path try { - const result = await syncWebhooksForCredentialSet({ + const syncResult = await syncWebhooksForCredentialSet({ workflowId: representativeWebhook.workflowId, blockId: representativeWebhook.blockId || '', provider: representativeWebhook.provider, @@ -2563,11 +352,11 @@ export async function syncAllWebhooksForCredentialSet( }) workflowsUpdated++ - totalCreated += result.created - totalDeleted += result.deleted + totalCreated += syncResult.created + totalDeleted += syncResult.deleted syncLogger.debug( - `[${requestId}] Synced webhooks for ${key}: ${result.created} created, ${result.deleted} deleted` + `[${requestId}] Synced webhooks for ${key}: ${syncResult.created} created, ${syncResult.deleted} deleted` ) } catch (error) { syncLogger.error(`[${requestId}] Error syncing webhooks for ${key}`, error) @@ -2580,332 +369,3 @@ export async function syncAllWebhooksForCredentialSet( return { workflowsUpdated, totalCreated, totalDeleted } } - -/** - * Configure Gmail polling for a webhook. - * Each webhook has its own credentialId (credential sets are fanned out at save time). - */ -export async function configureGmailPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('GmailWebhookSetup') - logger.info(`[${requestId}] Setting up Gmail polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Gmail webhook ${webhookData.id}`) - return false - } - - const resolvedGmail = await resolveOAuthAccountId(credentialId) - if (!resolvedGmail) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Gmail webhook ${webhookData.id}` - ) - return false - } - - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedGmail.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Gmail webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedGmail.accountId, - effectiveUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Gmail token for credential ${credentialId}` - ) - return false - } - - const maxEmailsPerPoll = - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : providerConfig.maxEmailsPerPoll || 25 - - const pollingInterval = - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : providerConfig.pollingInterval || 5 - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll, - pollingInterval, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - labelIds: providerConfig.labelIds || ['INBOX'], - labelFilterBehavior: providerConfig.labelFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Gmail polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Gmail polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false - } -} - -/** - * Configure Outlook polling for a webhook. - * Each webhook has its own credentialId (credential sets are fanned out at save time). - */ -export async function configureOutlookPolling( - webhookData: any, - requestId: string -): Promise { - const logger = createLogger('OutlookWebhookSetup') - logger.info(`[${requestId}] Setting up Outlook polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const credentialId: string | undefined = providerConfig.credentialId - - if (!credentialId) { - logger.error(`[${requestId}] Missing credentialId for Outlook webhook ${webhookData.id}`) - return false - } - - const resolvedOutlook = await resolveOAuthAccountId(credentialId) - if (!resolvedOutlook) { - logger.error( - `[${requestId}] Could not resolve credential ${credentialId} for Outlook webhook ${webhookData.id}` - ) - return false - } - - const rows = await db - .select() - .from(account) - .where(eq(account.id, resolvedOutlook.accountId)) - .limit(1) - if (rows.length === 0) { - logger.error( - `[${requestId}] Credential ${credentialId} not found for Outlook webhook ${webhookData.id}` - ) - return false - } - - const effectiveUserId = rows[0].userId - - const accessToken = await refreshAccessTokenIfNeeded( - resolvedOutlook.accountId, - effectiveUserId, - requestId - ) - if (!accessToken) { - logger.error( - `[${requestId}] Failed to refresh/access Outlook token for credential ${credentialId}` - ) - return false - } - - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - userId: effectiveUserId, - credentialId, - maxEmailsPerPoll: - typeof providerConfig.maxEmailsPerPoll === 'string' - ? Number.parseInt(providerConfig.maxEmailsPerPoll, 10) || 25 - : providerConfig.maxEmailsPerPoll || 25, - pollingInterval: - typeof providerConfig.pollingInterval === 'string' - ? Number.parseInt(providerConfig.pollingInterval, 10) || 5 - : providerConfig.pollingInterval || 5, - markAsRead: providerConfig.markAsRead || false, - includeRawEmail: providerConfig.includeRawEmail || false, - folderIds: providerConfig.folderIds || ['inbox'], - folderFilterBehavior: providerConfig.folderFilterBehavior || 'INCLUDE', - lastCheckedTimestamp: providerConfig.lastCheckedTimestamp || now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info( - `[${requestId}] Successfully configured Outlook polling for webhook ${webhookData.id}` - ) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure Outlook polling`, { - webhookId: webhookData.id, - error: error.message, - stack: error.stack, - }) - return false - } -} - -/** - * Configure RSS polling for a webhook - */ -export async function configureRssPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('RssWebhookSetup') - logger.info(`[${requestId}] Setting up RSS polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const now = new Date() - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - lastCheckedTimestamp: now.toISOString(), - lastSeenGuids: [], - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info(`[${requestId}] Successfully configured RSS polling for webhook ${webhookData.id}`) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure RSS polling`, { - webhookId: webhookData.id, - error: error.message, - }) - return false - } -} - -/** - * Configure IMAP polling for a webhook - */ -export async function configureImapPolling(webhookData: any, requestId: string): Promise { - const logger = createLogger('ImapWebhookSetup') - logger.info(`[${requestId}] Setting up IMAP polling for webhook ${webhookData.id}`) - - try { - const providerConfig = (webhookData.providerConfig as Record) || {} - const now = new Date() - - if (!providerConfig.host || !providerConfig.username || !providerConfig.password) { - logger.error( - `[${requestId}] Missing required IMAP connection settings for webhook ${webhookData.id}` - ) - return false - } - - await db - .update(webhook) - .set({ - providerConfig: { - ...providerConfig, - port: providerConfig.port || '993', - secure: providerConfig.secure !== false, - mailbox: providerConfig.mailbox || 'INBOX', - searchCriteria: providerConfig.searchCriteria || 'UNSEEN', - markAsRead: providerConfig.markAsRead || false, - includeAttachments: providerConfig.includeAttachments !== false, - lastCheckedTimestamp: now.toISOString(), - setupCompleted: true, - }, - updatedAt: now, - }) - .where(eq(webhook.id, webhookData.id)) - - logger.info(`[${requestId}] Successfully configured IMAP polling for webhook ${webhookData.id}`) - return true - } catch (error: any) { - logger.error(`[${requestId}] Failed to configure IMAP polling`, { - webhookId: webhookData.id, - error: error.message, - }) - return false - } -} - -export function convertSquareBracketsToTwiML(twiml: string | undefined): string | undefined { - if (!twiml) { - return twiml - } - - // Replace [Tag] with and [/Tag] with - return twiml.replace(/\[(\/?[^\]]+)\]/g, '<$1>') -} - -/** - * Validates a Cal.com webhook request signature using HMAC SHA-256 - * @param secret - Cal.com webhook secret (plain text) - * @param signature - X-Cal-Signature-256 header value (hex-encoded HMAC SHA-256 signature) - * @param body - Raw request body string - * @returns Whether the signature is valid - */ -export function validateCalcomSignature(secret: string, signature: string, body: string): boolean { - try { - if (!secret || !signature || !body) { - logger.warn('Cal.com signature validation missing required fields', { - hasSecret: !!secret, - hasSignature: !!signature, - hasBody: !!body, - }) - return false - } - - // Cal.com sends signature in format: sha256= - // We need to strip the prefix before comparing - let providedSignature: string - if (signature.startsWith('sha256=')) { - providedSignature = signature.substring(7) - } else { - // If no prefix, use as-is (for backwards compatibility) - providedSignature = signature - } - - const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') - - logger.debug('Cal.com signature comparison', { - computedSignature: `${computedHash.substring(0, 10)}...`, - providedSignature: `${providedSignature.substring(0, 10)}...`, - computedLength: computedHash.length, - providedLength: providedSignature.length, - match: computedHash === providedSignature, - }) - - return safeCompare(computedHash, providedSignature) - } catch (error) { - logger.error('Error validating Cal.com signature:', error) - return false - } -} From 925be3d635d825f46610a084b99676ae68b0e08b Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 11:06:36 -0700 Subject: [PATCH 02/32] feat(triggers): add Salesforce webhook triggers (#3982) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(triggers): add Salesforce webhook triggers * fix(triggers): address PR review — remove non-TSDoc comment, fix generic webhook instructions --- apps/sim/blocks/blocks/salesforce.ts | 18 ++ apps/sim/triggers/registry.ts | 14 ++ .../salesforce/case_status_changed.ts | 35 ++++ apps/sim/triggers/salesforce/index.ts | 6 + .../salesforce/opportunity_stage_changed.ts | 35 ++++ .../sim/triggers/salesforce/record_created.ts | 40 +++++ .../sim/triggers/salesforce/record_deleted.ts | 37 +++++ .../sim/triggers/salesforce/record_updated.ts | 37 +++++ apps/sim/triggers/salesforce/utils.ts | 154 ++++++++++++++++++ apps/sim/triggers/salesforce/webhook.ts | 37 +++++ 10 files changed, 413 insertions(+) create mode 100644 apps/sim/triggers/salesforce/case_status_changed.ts create mode 100644 apps/sim/triggers/salesforce/index.ts create mode 100644 apps/sim/triggers/salesforce/opportunity_stage_changed.ts create mode 100644 apps/sim/triggers/salesforce/record_created.ts create mode 100644 apps/sim/triggers/salesforce/record_deleted.ts create mode 100644 apps/sim/triggers/salesforce/record_updated.ts create mode 100644 apps/sim/triggers/salesforce/utils.ts create mode 100644 apps/sim/triggers/salesforce/webhook.ts diff --git a/apps/sim/blocks/blocks/salesforce.ts b/apps/sim/blocks/blocks/salesforce.ts index 53a9d67adae..53302520ca9 100644 --- a/apps/sim/blocks/blocks/salesforce.ts +++ b/apps/sim/blocks/blocks/salesforce.ts @@ -3,6 +3,7 @@ import { getScopesForService } from '@/lib/oauth/utils' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import type { SalesforceResponse } from '@/tools/salesforce/types' +import { getTrigger } from '@/triggers' export const SalesforceBlock: BlockConfig = { type: 'salesforce', @@ -17,6 +18,17 @@ export const SalesforceBlock: BlockConfig = { tags: ['sales-engagement', 'customer-support'], bgColor: '#E0E0E0', icon: SalesforceIcon, + triggers: { + enabled: true, + available: [ + 'salesforce_record_created', + 'salesforce_record_updated', + 'salesforce_record_deleted', + 'salesforce_opportunity_stage_changed', + 'salesforce_case_status_changed', + 'salesforce_webhook', + ], + }, subBlocks: [ { id: 'operation', @@ -511,6 +523,12 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n ], }, }, + ...getTrigger('salesforce_record_created').subBlocks, + ...getTrigger('salesforce_record_updated').subBlocks, + ...getTrigger('salesforce_record_deleted').subBlocks, + ...getTrigger('salesforce_opportunity_stage_changed').subBlocks, + ...getTrigger('salesforce_case_status_changed').subBlocks, + ...getTrigger('salesforce_webhook').subBlocks, ], tools: { access: [ diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 4390bfeefff..9671a62c9f7 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -163,6 +163,14 @@ import { } from '@/triggers/microsoftteams' import { outlookPollingTrigger } from '@/triggers/outlook' import { rssPollingTrigger } from '@/triggers/rss' +import { + salesforceCaseStatusChangedTrigger, + salesforceOpportunityStageChangedTrigger, + salesforceRecordCreatedTrigger, + salesforceRecordDeletedTrigger, + salesforceRecordUpdatedTrigger, + salesforceWebhookTrigger, +} from '@/triggers/salesforce' import { slackWebhookTrigger } from '@/triggers/slack' import { stripeWebhookTrigger } from '@/triggers/stripe' import { telegramWebhookTrigger } from '@/triggers/telegram' @@ -299,6 +307,12 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger, outlook_poller: outlookPollingTrigger, rss_poller: rssPollingTrigger, + salesforce_record_created: salesforceRecordCreatedTrigger, + salesforce_record_updated: salesforceRecordUpdatedTrigger, + salesforce_record_deleted: salesforceRecordDeletedTrigger, + salesforce_opportunity_stage_changed: salesforceOpportunityStageChangedTrigger, + salesforce_case_status_changed: salesforceCaseStatusChangedTrigger, + salesforce_webhook: salesforceWebhookTrigger, stripe_webhook: stripeWebhookTrigger, telegram_webhook: telegramWebhookTrigger, typeform_webhook: typeformWebhookTrigger, diff --git a/apps/sim/triggers/salesforce/case_status_changed.ts b/apps/sim/triggers/salesforce/case_status_changed.ts new file mode 100644 index 00000000000..a3ad5802112 --- /dev/null +++ b/apps/sim/triggers/salesforce/case_status_changed.ts @@ -0,0 +1,35 @@ +import { SalesforceIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildSalesforceCaseStatusOutputs, + salesforceSetupInstructions, + salesforceTriggerOptions, +} from '@/triggers/salesforce/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Salesforce Case Status Changed Trigger + */ +export const salesforceCaseStatusChangedTrigger: TriggerConfig = { + id: 'salesforce_case_status_changed', + name: 'Salesforce Case Status Changed', + provider: 'salesforce', + description: 'Trigger workflow when a case status changes', + version: '1.0.0', + icon: SalesforceIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'salesforce_case_status_changed', + triggerOptions: salesforceTriggerOptions, + setupInstructions: salesforceSetupInstructions('Case Status Changed'), + }), + + outputs: buildSalesforceCaseStatusOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/salesforce/index.ts b/apps/sim/triggers/salesforce/index.ts new file mode 100644 index 00000000000..93d02ffb9f9 --- /dev/null +++ b/apps/sim/triggers/salesforce/index.ts @@ -0,0 +1,6 @@ +export { salesforceCaseStatusChangedTrigger } from './case_status_changed' +export { salesforceOpportunityStageChangedTrigger } from './opportunity_stage_changed' +export { salesforceRecordCreatedTrigger } from './record_created' +export { salesforceRecordDeletedTrigger } from './record_deleted' +export { salesforceRecordUpdatedTrigger } from './record_updated' +export { salesforceWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/salesforce/opportunity_stage_changed.ts b/apps/sim/triggers/salesforce/opportunity_stage_changed.ts new file mode 100644 index 00000000000..43d72a972c3 --- /dev/null +++ b/apps/sim/triggers/salesforce/opportunity_stage_changed.ts @@ -0,0 +1,35 @@ +import { SalesforceIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildSalesforceOpportunityStageOutputs, + salesforceSetupInstructions, + salesforceTriggerOptions, +} from '@/triggers/salesforce/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Salesforce Opportunity Stage Changed Trigger + */ +export const salesforceOpportunityStageChangedTrigger: TriggerConfig = { + id: 'salesforce_opportunity_stage_changed', + name: 'Salesforce Opportunity Stage Changed', + provider: 'salesforce', + description: 'Trigger workflow when an opportunity stage changes', + version: '1.0.0', + icon: SalesforceIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'salesforce_opportunity_stage_changed', + triggerOptions: salesforceTriggerOptions, + setupInstructions: salesforceSetupInstructions('Opportunity Stage Changed'), + }), + + outputs: buildSalesforceOpportunityStageOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/salesforce/record_created.ts b/apps/sim/triggers/salesforce/record_created.ts new file mode 100644 index 00000000000..a1d3adf4224 --- /dev/null +++ b/apps/sim/triggers/salesforce/record_created.ts @@ -0,0 +1,40 @@ +import { SalesforceIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildSalesforceExtraFields, + buildSalesforceRecordOutputs, + salesforceSetupInstructions, + salesforceTriggerOptions, +} from '@/triggers/salesforce/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Salesforce Record Created Trigger + * + * PRIMARY trigger — includes the dropdown for selecting trigger type. + */ +export const salesforceRecordCreatedTrigger: TriggerConfig = { + id: 'salesforce_record_created', + name: 'Salesforce Record Created', + provider: 'salesforce', + description: 'Trigger workflow when a Salesforce record is created', + version: '1.0.0', + icon: SalesforceIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'salesforce_record_created', + triggerOptions: salesforceTriggerOptions, + includeDropdown: true, + setupInstructions: salesforceSetupInstructions('Record Created'), + extraFields: buildSalesforceExtraFields('salesforce_record_created'), + }), + + outputs: buildSalesforceRecordOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/salesforce/record_deleted.ts b/apps/sim/triggers/salesforce/record_deleted.ts new file mode 100644 index 00000000000..72275317ec1 --- /dev/null +++ b/apps/sim/triggers/salesforce/record_deleted.ts @@ -0,0 +1,37 @@ +import { SalesforceIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildSalesforceExtraFields, + buildSalesforceRecordOutputs, + salesforceSetupInstructions, + salesforceTriggerOptions, +} from '@/triggers/salesforce/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Salesforce Record Deleted Trigger + */ +export const salesforceRecordDeletedTrigger: TriggerConfig = { + id: 'salesforce_record_deleted', + name: 'Salesforce Record Deleted', + provider: 'salesforce', + description: 'Trigger workflow when a Salesforce record is deleted', + version: '1.0.0', + icon: SalesforceIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'salesforce_record_deleted', + triggerOptions: salesforceTriggerOptions, + setupInstructions: salesforceSetupInstructions('Record Deleted'), + extraFields: buildSalesforceExtraFields('salesforce_record_deleted'), + }), + + outputs: buildSalesforceRecordOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/salesforce/record_updated.ts b/apps/sim/triggers/salesforce/record_updated.ts new file mode 100644 index 00000000000..aac05c02a1c --- /dev/null +++ b/apps/sim/triggers/salesforce/record_updated.ts @@ -0,0 +1,37 @@ +import { SalesforceIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildSalesforceExtraFields, + buildSalesforceRecordOutputs, + salesforceSetupInstructions, + salesforceTriggerOptions, +} from '@/triggers/salesforce/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Salesforce Record Updated Trigger + */ +export const salesforceRecordUpdatedTrigger: TriggerConfig = { + id: 'salesforce_record_updated', + name: 'Salesforce Record Updated', + provider: 'salesforce', + description: 'Trigger workflow when a Salesforce record is updated', + version: '1.0.0', + icon: SalesforceIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'salesforce_record_updated', + triggerOptions: salesforceTriggerOptions, + setupInstructions: salesforceSetupInstructions('Record Updated'), + extraFields: buildSalesforceExtraFields('salesforce_record_updated'), + }), + + outputs: buildSalesforceRecordOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts new file mode 100644 index 00000000000..a2c1db4b715 --- /dev/null +++ b/apps/sim/triggers/salesforce/utils.ts @@ -0,0 +1,154 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Dropdown options for the Salesforce trigger type selector. + */ +export const salesforceTriggerOptions = [ + { label: 'Record Created', id: 'salesforce_record_created' }, + { label: 'Record Updated', id: 'salesforce_record_updated' }, + { label: 'Record Deleted', id: 'salesforce_record_deleted' }, + { label: 'Opportunity Stage Changed', id: 'salesforce_opportunity_stage_changed' }, + { label: 'Case Status Changed', id: 'salesforce_case_status_changed' }, + { label: 'Generic Webhook (All Events)', id: 'salesforce_webhook' }, +] + +/** + * Generates HTML setup instructions for the Salesforce trigger. + * Salesforce has no native webhook API — users must configure + * Flow HTTP Callouts or Outbound Messages manually. + */ +export function salesforceSetupInstructions(eventType: string): string { + const isGeneric = eventType === 'All Events' + + const instructions = isGeneric + ? [ + 'Copy the Webhook URL above.', + 'In Salesforce, go to Setup → Flows and click New Flow.', + 'Select Record-Triggered Flow and choose the object(s) you want to monitor.', + 'Add an HTTP Callout action — set the method to POST and paste the webhook URL.', + 'In the request body, include the record fields you want sent as JSON (e.g., Id, Name, and any relevant fields).', + 'Repeat for each object type you want to send events for.', + 'Save and Activate the Flow(s).', + 'Click "Save" above to activate your trigger.', + ] + : [ + 'Copy the Webhook URL above.', + 'In Salesforce, go to Setup → Flows and click New Flow.', + `Select Record-Triggered Flow and choose the object and ${eventType} trigger condition.`, + 'Add an HTTP Callout action — set the method to POST and paste the webhook URL.', + 'In the request body, include the record fields you want sent as JSON (e.g., Id, Name, and any relevant fields).', + 'Save and Activate the Flow.', + 'Click "Save" above to activate your trigger.', + 'Alternative: You can also use Setup → Outbound Messages with a Workflow Rule, but this sends SOAP/XML instead of JSON.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Extra fields for Salesforce triggers (object type filter). + */ +export function buildSalesforceExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'objectType', + title: 'Object Type (Optional)', + type: 'short-input', + placeholder: 'e.g., Account, Contact, Lead, Opportunity', + description: 'Optionally filter to a specific Salesforce object type', + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Outputs for record lifecycle events (created, updated, deleted). + */ +export function buildSalesforceRecordOutputs(): Record { + return { + eventType: { + type: 'string', + description: 'The type of event (e.g., created, updated, deleted)', + }, + objectType: { + type: 'string', + description: 'Salesforce object type (e.g., Account, Contact, Lead)', + }, + recordId: { type: 'string', description: 'ID of the affected record' }, + timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + record: { + Id: { type: 'string', description: 'Record ID' }, + Name: { type: 'string', description: 'Record name' }, + CreatedDate: { type: 'string', description: 'Record creation date' }, + LastModifiedDate: { type: 'string', description: 'Last modification date' }, + }, + changedFields: { type: 'json', description: 'Fields that were changed (for update events)' }, + payload: { type: 'json', description: 'Full webhook payload' }, + } +} + +/** + * Outputs for opportunity stage change events. + */ +export function buildSalesforceOpportunityStageOutputs(): Record { + return { + eventType: { type: 'string', description: 'The type of event' }, + objectType: { type: 'string', description: 'Salesforce object type (Opportunity)' }, + recordId: { type: 'string', description: 'Opportunity ID' }, + timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + record: { + Id: { type: 'string', description: 'Opportunity ID' }, + Name: { type: 'string', description: 'Opportunity name' }, + StageName: { type: 'string', description: 'Current stage name' }, + Amount: { type: 'string', description: 'Deal amount' }, + CloseDate: { type: 'string', description: 'Expected close date' }, + Probability: { type: 'string', description: 'Win probability' }, + }, + previousStage: { type: 'string', description: 'Previous stage name' }, + newStage: { type: 'string', description: 'New stage name' }, + payload: { type: 'json', description: 'Full webhook payload' }, + } +} + +/** + * Outputs for case status change events. + */ +export function buildSalesforceCaseStatusOutputs(): Record { + return { + eventType: { type: 'string', description: 'The type of event' }, + objectType: { type: 'string', description: 'Salesforce object type (Case)' }, + recordId: { type: 'string', description: 'Case ID' }, + timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + record: { + Id: { type: 'string', description: 'Case ID' }, + Subject: { type: 'string', description: 'Case subject' }, + Status: { type: 'string', description: 'Current case status' }, + Priority: { type: 'string', description: 'Case priority' }, + CaseNumber: { type: 'string', description: 'Case number' }, + }, + previousStatus: { type: 'string', description: 'Previous case status' }, + newStatus: { type: 'string', description: 'New case status' }, + payload: { type: 'json', description: 'Full webhook payload' }, + } +} + +/** + * Outputs for the generic webhook trigger. + */ +export function buildSalesforceWebhookOutputs(): Record { + return { + eventType: { type: 'string', description: 'The type of event' }, + objectType: { type: 'string', description: 'Salesforce object type' }, + recordId: { type: 'string', description: 'ID of the affected record' }, + timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' }, + record: { type: 'json', description: 'Full record data' }, + payload: { type: 'json', description: 'Full webhook payload' }, + } +} diff --git a/apps/sim/triggers/salesforce/webhook.ts b/apps/sim/triggers/salesforce/webhook.ts new file mode 100644 index 00000000000..32d0165db24 --- /dev/null +++ b/apps/sim/triggers/salesforce/webhook.ts @@ -0,0 +1,37 @@ +import { SalesforceIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildSalesforceWebhookOutputs, + salesforceSetupInstructions, + salesforceTriggerOptions, +} from '@/triggers/salesforce/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Salesforce Generic Webhook Trigger + * + * Receives all Salesforce events via a single webhook endpoint. + */ +export const salesforceWebhookTrigger: TriggerConfig = { + id: 'salesforce_webhook', + name: 'Salesforce Webhook (All Events)', + provider: 'salesforce', + description: 'Trigger workflow on any Salesforce webhook event', + version: '1.0.0', + icon: SalesforceIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'salesforce_webhook', + triggerOptions: salesforceTriggerOptions, + setupInstructions: salesforceSetupInstructions('All Events'), + }), + + outputs: buildSalesforceWebhookOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} From c9b45f4f28b648532dc17ebe4e48813bc0148d75 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 11:15:13 -0700 Subject: [PATCH 03/32] feat(triggers): add HubSpot merge, restore, and generic webhook triggers (#3983) * feat(triggers): add HubSpot merge, restore, and generic webhook triggers * fix(triggers): add mergedObjectIds to merge trigger output schemas * fix(triggers): derive correct OAuth scope per HubSpot object type in setup instructions * lint --- apps/sim/blocks/blocks/hubspot.ts | 18 ++ apps/sim/triggers/hubspot/company_merged.ts | 217 +++++++++++++++++ apps/sim/triggers/hubspot/company_restored.ts | 216 +++++++++++++++++ apps/sim/triggers/hubspot/contact_merged.ts | 217 +++++++++++++++++ apps/sim/triggers/hubspot/contact_restored.ts | 216 +++++++++++++++++ apps/sim/triggers/hubspot/deal_merged.ts | 217 +++++++++++++++++ apps/sim/triggers/hubspot/deal_restored.ts | 216 +++++++++++++++++ apps/sim/triggers/hubspot/index.ts | 9 + apps/sim/triggers/hubspot/ticket_merged.ts | 217 +++++++++++++++++ apps/sim/triggers/hubspot/ticket_restored.ts | 216 +++++++++++++++++ apps/sim/triggers/hubspot/utils.ts | 173 +++++++++++++- apps/sim/triggers/hubspot/webhook.ts | 223 ++++++++++++++++++ apps/sim/triggers/registry.ts | 18 ++ 13 files changed, 2172 insertions(+), 1 deletion(-) create mode 100644 apps/sim/triggers/hubspot/company_merged.ts create mode 100644 apps/sim/triggers/hubspot/company_restored.ts create mode 100644 apps/sim/triggers/hubspot/contact_merged.ts create mode 100644 apps/sim/triggers/hubspot/contact_restored.ts create mode 100644 apps/sim/triggers/hubspot/deal_merged.ts create mode 100644 apps/sim/triggers/hubspot/deal_restored.ts create mode 100644 apps/sim/triggers/hubspot/ticket_merged.ts create mode 100644 apps/sim/triggers/hubspot/ticket_restored.ts create mode 100644 apps/sim/triggers/hubspot/webhook.ts diff --git a/apps/sim/blocks/blocks/hubspot.ts b/apps/sim/blocks/blocks/hubspot.ts index 165c0b02bf4..5cbd9210264 100644 --- a/apps/sim/blocks/blocks/hubspot.ts +++ b/apps/sim/blocks/blocks/hubspot.ts @@ -985,11 +985,15 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no }, ...getTrigger('hubspot_contact_created').subBlocks.slice(1), ...getTrigger('hubspot_contact_deleted').subBlocks.slice(1), + ...getTrigger('hubspot_contact_merged').subBlocks.slice(1), ...getTrigger('hubspot_contact_privacy_deleted').subBlocks.slice(1), ...getTrigger('hubspot_contact_property_changed').subBlocks.slice(1), + ...getTrigger('hubspot_contact_restored').subBlocks.slice(1), ...getTrigger('hubspot_company_created').subBlocks.slice(1), ...getTrigger('hubspot_company_deleted').subBlocks.slice(1), + ...getTrigger('hubspot_company_merged').subBlocks.slice(1), ...getTrigger('hubspot_company_property_changed').subBlocks.slice(1), + ...getTrigger('hubspot_company_restored').subBlocks.slice(1), ...getTrigger('hubspot_conversation_creation').subBlocks.slice(1), ...getTrigger('hubspot_conversation_deletion').subBlocks.slice(1), ...getTrigger('hubspot_conversation_new_message').subBlocks.slice(1), @@ -997,10 +1001,15 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no ...getTrigger('hubspot_conversation_property_changed').subBlocks.slice(1), ...getTrigger('hubspot_deal_created').subBlocks.slice(1), ...getTrigger('hubspot_deal_deleted').subBlocks.slice(1), + ...getTrigger('hubspot_deal_merged').subBlocks.slice(1), ...getTrigger('hubspot_deal_property_changed').subBlocks.slice(1), + ...getTrigger('hubspot_deal_restored').subBlocks.slice(1), ...getTrigger('hubspot_ticket_created').subBlocks.slice(1), ...getTrigger('hubspot_ticket_deleted').subBlocks.slice(1), + ...getTrigger('hubspot_ticket_merged').subBlocks.slice(1), ...getTrigger('hubspot_ticket_property_changed').subBlocks.slice(1), + ...getTrigger('hubspot_ticket_restored').subBlocks.slice(1), + ...getTrigger('hubspot_webhook').subBlocks.slice(1), ], tools: { access: [ @@ -1329,11 +1338,15 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no available: [ 'hubspot_contact_created', 'hubspot_contact_deleted', + 'hubspot_contact_merged', 'hubspot_contact_privacy_deleted', 'hubspot_contact_property_changed', + 'hubspot_contact_restored', 'hubspot_company_created', 'hubspot_company_deleted', + 'hubspot_company_merged', 'hubspot_company_property_changed', + 'hubspot_company_restored', 'hubspot_conversation_creation', 'hubspot_conversation_deletion', 'hubspot_conversation_new_message', @@ -1341,10 +1354,15 @@ Return ONLY the JSON array of property names - no explanations, no markdown, no 'hubspot_conversation_property_changed', 'hubspot_deal_created', 'hubspot_deal_deleted', + 'hubspot_deal_merged', 'hubspot_deal_property_changed', + 'hubspot_deal_restored', 'hubspot_ticket_created', 'hubspot_ticket_deleted', + 'hubspot_ticket_merged', 'hubspot_ticket_property_changed', + 'hubspot_ticket_restored', + 'hubspot_webhook', ], }, } diff --git a/apps/sim/triggers/hubspot/company_merged.ts b/apps/sim/triggers/hubspot/company_merged.ts new file mode 100644 index 00000000000..4d64cf7ad3e --- /dev/null +++ b/apps/sim/triggers/hubspot/company_merged.ts @@ -0,0 +1,217 @@ +import { HubspotIcon } from '@/components/icons' +import { + buildCompanyMergedOutputs, + hubspotCompanyTriggerOptions, + hubspotSetupInstructions, +} from '@/triggers/hubspot/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const hubspotCompanyMergedTrigger: TriggerConfig = { + id: 'hubspot_company_merged', + name: 'HubSpot Company Merged', + provider: 'hubspot', + description: 'Trigger workflow when companies are merged in HubSpot', + version: '1.0.0', + icon: HubspotIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: hubspotCompanyTriggerOptions, + value: () => 'hubspot_company_merged', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client ID', + description: 'Found in your HubSpot app settings under Auth tab', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client Secret', + description: 'Found in your HubSpot app settings under Auth tab', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'Enter your HubSpot App ID', + description: 'Found in your HubSpot app settings. Used to identify your app.', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + { + id: 'developerApiKey', + title: 'Developer API Key', + type: 'short-input', + placeholder: 'Enter your HubSpot Developer API Key', + description: 'Used for making API calls to HubSpot. Found in your HubSpot app settings.', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + description: 'Copy this URL and paste it into your HubSpot app webhook subscription settings', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'hubspot_company_merged', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: hubspotSetupInstructions( + 'company.merge', + 'The webhook will trigger whenever two companies are merged in your HubSpot account. The objectId will be the winning (primary) company ID.' + ), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + { + id: 'curlSetWebhookUrl', + title: '1. Set Webhook Target URL', + type: 'code', + language: 'javascript', + value: (params: Record) => { + const webhookUrl = params.webhookUrlDisplay || '{YOUR_WEBHOOK_URL_FROM_ABOVE}' + return `curl -X PUT "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/settings?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "targetUrl": "${webhookUrl}", + "throttling": { + "maxConcurrentRequests": 10 + } + }'` + }, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to set your webhook URL in HubSpot', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + { + id: 'curlCreateSubscription', + title: '2. Create Webhook Subscription', + type: 'code', + language: 'javascript', + defaultValue: `curl -X POST "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/subscriptions?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "eventType": "company.merge", + "active": true + }'`, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to subscribe to company merge events', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + [ + { + eventId: 3181526813, + subscriptionId: 4629974, + portalId: 244315265, + appId: 23608917, + occurredAt: 1762659213730, + subscriptionType: 'company.merge', + attemptNumber: 0, + objectId: 216126906049, + changeFlag: 'MERGED', + changeSource: 'CRM_UI', + sourceId: 'userId:84916424', + mergedObjectIds: [216126906050], + }, + ], + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_merged', + }, + }, + ], + + outputs: buildCompanyMergedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-HubSpot-Signature': 'sha256=...', + 'X-HubSpot-Request-Id': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'User-Agent': 'HubSpot Webhooks', + }, + }, +} diff --git a/apps/sim/triggers/hubspot/company_restored.ts b/apps/sim/triggers/hubspot/company_restored.ts new file mode 100644 index 00000000000..a30528c6954 --- /dev/null +++ b/apps/sim/triggers/hubspot/company_restored.ts @@ -0,0 +1,216 @@ +import { HubspotIcon } from '@/components/icons' +import { + buildCompanyRestoredOutputs, + hubspotCompanyTriggerOptions, + hubspotSetupInstructions, +} from '@/triggers/hubspot/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const hubspotCompanyRestoredTrigger: TriggerConfig = { + id: 'hubspot_company_restored', + name: 'HubSpot Company Restored', + provider: 'hubspot', + description: 'Trigger workflow when a deleted company is restored in HubSpot', + version: '1.0.0', + icon: HubspotIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: hubspotCompanyTriggerOptions, + value: () => 'hubspot_company_restored', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client ID', + description: 'Found in your HubSpot app settings under Auth tab', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client Secret', + description: 'Found in your HubSpot app settings under Auth tab', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'Enter your HubSpot App ID', + description: 'Found in your HubSpot app settings. Used to identify your app.', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + { + id: 'developerApiKey', + title: 'Developer API Key', + type: 'short-input', + placeholder: 'Enter your HubSpot Developer API Key', + description: 'Used for making API calls to HubSpot. Found in your HubSpot app settings.', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + description: 'Copy this URL and paste it into your HubSpot app webhook subscription settings', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'hubspot_company_restored', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: hubspotSetupInstructions( + 'company.restore', + 'The webhook will trigger whenever a previously deleted company is restored in your HubSpot account.' + ), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + { + id: 'curlSetWebhookUrl', + title: '1. Set Webhook Target URL', + type: 'code', + language: 'javascript', + value: (params: Record) => { + const webhookUrl = params.webhookUrlDisplay || '{YOUR_WEBHOOK_URL_FROM_ABOVE}' + return `curl -X PUT "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/settings?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "targetUrl": "${webhookUrl}", + "throttling": { + "maxConcurrentRequests": 10 + } + }'` + }, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to set your webhook URL in HubSpot', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + { + id: 'curlCreateSubscription', + title: '2. Create Webhook Subscription', + type: 'code', + language: 'javascript', + defaultValue: `curl -X POST "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/subscriptions?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "eventType": "company.restore", + "active": true + }'`, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to subscribe to company restore events', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + [ + { + eventId: 3181526814, + subscriptionId: 4629975, + portalId: 244315265, + appId: 23608917, + occurredAt: 1762659213730, + subscriptionType: 'company.restore', + attemptNumber: 0, + objectId: 216126906049, + changeFlag: 'RESTORED', + changeSource: 'CRM_UI', + sourceId: 'userId:84916424', + }, + ], + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_company_restored', + }, + }, + ], + + outputs: buildCompanyRestoredOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-HubSpot-Signature': 'sha256=...', + 'X-HubSpot-Request-Id': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'User-Agent': 'HubSpot Webhooks', + }, + }, +} diff --git a/apps/sim/triggers/hubspot/contact_merged.ts b/apps/sim/triggers/hubspot/contact_merged.ts new file mode 100644 index 00000000000..435399e7706 --- /dev/null +++ b/apps/sim/triggers/hubspot/contact_merged.ts @@ -0,0 +1,217 @@ +import { HubspotIcon } from '@/components/icons' +import { + buildContactMergedOutputs, + hubspotContactTriggerOptions, + hubspotSetupInstructions, +} from '@/triggers/hubspot/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const hubspotContactMergedTrigger: TriggerConfig = { + id: 'hubspot_contact_merged', + name: 'HubSpot Contact Merged', + provider: 'hubspot', + description: 'Trigger workflow when contacts are merged in HubSpot', + version: '1.0.0', + icon: HubspotIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: hubspotContactTriggerOptions, + value: () => 'hubspot_contact_merged', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client ID', + description: 'Found in your HubSpot app settings under Auth tab', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client Secret', + description: 'Found in your HubSpot app settings under Auth tab', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'Enter your HubSpot App ID', + description: 'Found in your HubSpot app settings. Used to identify your app.', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + { + id: 'developerApiKey', + title: 'Developer API Key', + type: 'short-input', + placeholder: 'Enter your HubSpot Developer API Key', + description: 'Used for making API calls to HubSpot. Found in your HubSpot app settings.', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + description: 'Copy this URL and paste it into your HubSpot app webhook subscription settings', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'hubspot_contact_merged', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: hubspotSetupInstructions( + 'contact.merge', + 'The webhook will trigger whenever two contacts are merged in your HubSpot account. The objectId will be the winning (primary) contact ID.' + ), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + { + id: 'curlSetWebhookUrl', + title: '1. Set Webhook Target URL', + type: 'code', + language: 'javascript', + value: (params: Record) => { + const webhookUrl = params.webhookUrlDisplay || '{YOUR_WEBHOOK_URL_FROM_ABOVE}' + return `curl -X PUT "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/settings?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "targetUrl": "${webhookUrl}", + "throttling": { + "maxConcurrentRequests": 10 + } + }'` + }, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to set your webhook URL in HubSpot', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + { + id: 'curlCreateSubscription', + title: '2. Create Webhook Subscription', + type: 'code', + language: 'javascript', + defaultValue: `curl -X POST "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/subscriptions?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "eventType": "contact.merge", + "active": true + }'`, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to subscribe to contact merge events', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + [ + { + eventId: 3181526811, + subscriptionId: 4629972, + portalId: 244315265, + appId: 23608917, + occurredAt: 1762659213730, + subscriptionType: 'contact.merge', + attemptNumber: 0, + objectId: 316126906049, + changeFlag: 'MERGED', + changeSource: 'CRM_UI', + sourceId: 'userId:84916424', + mergedObjectIds: [316126906050], + }, + ], + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_merged', + }, + }, + ], + + outputs: buildContactMergedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-HubSpot-Signature': 'sha256=...', + 'X-HubSpot-Request-Id': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'User-Agent': 'HubSpot Webhooks', + }, + }, +} diff --git a/apps/sim/triggers/hubspot/contact_restored.ts b/apps/sim/triggers/hubspot/contact_restored.ts new file mode 100644 index 00000000000..e3ad2113da6 --- /dev/null +++ b/apps/sim/triggers/hubspot/contact_restored.ts @@ -0,0 +1,216 @@ +import { HubspotIcon } from '@/components/icons' +import { + buildContactRestoredOutputs, + hubspotContactTriggerOptions, + hubspotSetupInstructions, +} from '@/triggers/hubspot/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const hubspotContactRestoredTrigger: TriggerConfig = { + id: 'hubspot_contact_restored', + name: 'HubSpot Contact Restored', + provider: 'hubspot', + description: 'Trigger workflow when a deleted contact is restored in HubSpot', + version: '1.0.0', + icon: HubspotIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: hubspotContactTriggerOptions, + value: () => 'hubspot_contact_restored', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client ID', + description: 'Found in your HubSpot app settings under Auth tab', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client Secret', + description: 'Found in your HubSpot app settings under Auth tab', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'Enter your HubSpot App ID', + description: 'Found in your HubSpot app settings. Used to identify your app.', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + { + id: 'developerApiKey', + title: 'Developer API Key', + type: 'short-input', + placeholder: 'Enter your HubSpot Developer API Key', + description: 'Used for making API calls to HubSpot. Found in your HubSpot app settings.', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + description: 'Copy this URL and paste it into your HubSpot app webhook subscription settings', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'hubspot_contact_restored', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: hubspotSetupInstructions( + 'contact.restore', + 'The webhook will trigger whenever a previously deleted contact is restored in your HubSpot account.' + ), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + { + id: 'curlSetWebhookUrl', + title: '1. Set Webhook Target URL', + type: 'code', + language: 'javascript', + value: (params: Record) => { + const webhookUrl = params.webhookUrlDisplay || '{YOUR_WEBHOOK_URL_FROM_ABOVE}' + return `curl -X PUT "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/settings?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "targetUrl": "${webhookUrl}", + "throttling": { + "maxConcurrentRequests": 10 + } + }'` + }, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to set your webhook URL in HubSpot', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + { + id: 'curlCreateSubscription', + title: '2. Create Webhook Subscription', + type: 'code', + language: 'javascript', + defaultValue: `curl -X POST "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/subscriptions?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "eventType": "contact.restore", + "active": true + }'`, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to subscribe to contact restore events', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + [ + { + eventId: 3181526812, + subscriptionId: 4629973, + portalId: 244315265, + appId: 23608917, + occurredAt: 1762659213730, + subscriptionType: 'contact.restore', + attemptNumber: 0, + objectId: 316126906049, + changeFlag: 'RESTORED', + changeSource: 'CRM_UI', + sourceId: 'userId:84916424', + }, + ], + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_contact_restored', + }, + }, + ], + + outputs: buildContactRestoredOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-HubSpot-Signature': 'sha256=...', + 'X-HubSpot-Request-Id': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'User-Agent': 'HubSpot Webhooks', + }, + }, +} diff --git a/apps/sim/triggers/hubspot/deal_merged.ts b/apps/sim/triggers/hubspot/deal_merged.ts new file mode 100644 index 00000000000..e6d875af02d --- /dev/null +++ b/apps/sim/triggers/hubspot/deal_merged.ts @@ -0,0 +1,217 @@ +import { HubspotIcon } from '@/components/icons' +import { + buildDealMergedOutputs, + hubspotDealTriggerOptions, + hubspotSetupInstructions, +} from '@/triggers/hubspot/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const hubspotDealMergedTrigger: TriggerConfig = { + id: 'hubspot_deal_merged', + name: 'HubSpot Deal Merged', + provider: 'hubspot', + description: 'Trigger workflow when deals are merged in HubSpot', + version: '1.0.0', + icon: HubspotIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: hubspotDealTriggerOptions, + value: () => 'hubspot_deal_merged', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client ID', + description: 'Found in your HubSpot app settings under Auth tab', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client Secret', + description: 'Found in your HubSpot app settings under Auth tab', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'Enter your HubSpot App ID', + description: 'Found in your HubSpot app settings. Used to identify your app.', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + { + id: 'developerApiKey', + title: 'Developer API Key', + type: 'short-input', + placeholder: 'Enter your HubSpot Developer API Key', + description: 'Used for making API calls to HubSpot. Found in your HubSpot app settings.', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + description: 'Copy this URL and paste it into your HubSpot app webhook subscription settings', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'hubspot_deal_merged', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: hubspotSetupInstructions( + 'deal.merge', + 'The webhook will trigger whenever two deals are merged in your HubSpot account. The objectId will be the winning (primary) deal ID.' + ), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + { + id: 'curlSetWebhookUrl', + title: '1. Set Webhook Target URL', + type: 'code', + language: 'javascript', + value: (params: Record) => { + const webhookUrl = params.webhookUrlDisplay || '{YOUR_WEBHOOK_URL_FROM_ABOVE}' + return `curl -X PUT "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/settings?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "targetUrl": "${webhookUrl}", + "throttling": { + "maxConcurrentRequests": 10 + } + }'` + }, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to set your webhook URL in HubSpot', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + { + id: 'curlCreateSubscription', + title: '2. Create Webhook Subscription', + type: 'code', + language: 'javascript', + defaultValue: `curl -X POST "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/subscriptions?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "eventType": "deal.merge", + "active": true + }'`, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to subscribe to deal merge events', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + [ + { + eventId: 3181526815, + subscriptionId: 4629976, + portalId: 244315265, + appId: 23608917, + occurredAt: 1762659213730, + subscriptionType: 'deal.merge', + attemptNumber: 0, + objectId: 416126906049, + changeFlag: 'MERGED', + changeSource: 'CRM_UI', + sourceId: 'userId:84916424', + mergedObjectIds: [416126906050], + }, + ], + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_merged', + }, + }, + ], + + outputs: buildDealMergedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-HubSpot-Signature': 'sha256=...', + 'X-HubSpot-Request-Id': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'User-Agent': 'HubSpot Webhooks', + }, + }, +} diff --git a/apps/sim/triggers/hubspot/deal_restored.ts b/apps/sim/triggers/hubspot/deal_restored.ts new file mode 100644 index 00000000000..ba3eb28b8c4 --- /dev/null +++ b/apps/sim/triggers/hubspot/deal_restored.ts @@ -0,0 +1,216 @@ +import { HubspotIcon } from '@/components/icons' +import { + buildDealRestoredOutputs, + hubspotDealTriggerOptions, + hubspotSetupInstructions, +} from '@/triggers/hubspot/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const hubspotDealRestoredTrigger: TriggerConfig = { + id: 'hubspot_deal_restored', + name: 'HubSpot Deal Restored', + provider: 'hubspot', + description: 'Trigger workflow when a deleted deal is restored in HubSpot', + version: '1.0.0', + icon: HubspotIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: hubspotDealTriggerOptions, + value: () => 'hubspot_deal_restored', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client ID', + description: 'Found in your HubSpot app settings under Auth tab', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client Secret', + description: 'Found in your HubSpot app settings under Auth tab', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'Enter your HubSpot App ID', + description: 'Found in your HubSpot app settings. Used to identify your app.', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + { + id: 'developerApiKey', + title: 'Developer API Key', + type: 'short-input', + placeholder: 'Enter your HubSpot Developer API Key', + description: 'Used for making API calls to HubSpot. Found in your HubSpot app settings.', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + description: 'Copy this URL and paste it into your HubSpot app webhook subscription settings', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'hubspot_deal_restored', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: hubspotSetupInstructions( + 'deal.restore', + 'The webhook will trigger whenever a previously deleted deal is restored in your HubSpot account.' + ), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + { + id: 'curlSetWebhookUrl', + title: '1. Set Webhook Target URL', + type: 'code', + language: 'javascript', + value: (params: Record) => { + const webhookUrl = params.webhookUrlDisplay || '{YOUR_WEBHOOK_URL_FROM_ABOVE}' + return `curl -X PUT "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/settings?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "targetUrl": "${webhookUrl}", + "throttling": { + "maxConcurrentRequests": 10 + } + }'` + }, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to set your webhook URL in HubSpot', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + { + id: 'curlCreateSubscription', + title: '2. Create Webhook Subscription', + type: 'code', + language: 'javascript', + defaultValue: `curl -X POST "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/subscriptions?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "eventType": "deal.restore", + "active": true + }'`, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to subscribe to deal restore events', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + [ + { + eventId: 3181526816, + subscriptionId: 4629977, + portalId: 244315265, + appId: 23608917, + occurredAt: 1762659213730, + subscriptionType: 'deal.restore', + attemptNumber: 0, + objectId: 416126906049, + changeFlag: 'RESTORED', + changeSource: 'CRM_UI', + sourceId: 'userId:84916424', + }, + ], + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_deal_restored', + }, + }, + ], + + outputs: buildDealRestoredOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-HubSpot-Signature': 'sha256=...', + 'X-HubSpot-Request-Id': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'User-Agent': 'HubSpot Webhooks', + }, + }, +} diff --git a/apps/sim/triggers/hubspot/index.ts b/apps/sim/triggers/hubspot/index.ts index 3150c828fec..2c8d6bb88c1 100644 --- a/apps/sim/triggers/hubspot/index.ts +++ b/apps/sim/triggers/hubspot/index.ts @@ -1,10 +1,14 @@ export { hubspotCompanyCreatedTrigger } from './company_created' export { hubspotCompanyDeletedTrigger } from './company_deleted' +export { hubspotCompanyMergedTrigger } from './company_merged' export { hubspotCompanyPropertyChangedTrigger } from './company_property_changed' +export { hubspotCompanyRestoredTrigger } from './company_restored' export { hubspotContactCreatedTrigger } from './contact_created' export { hubspotContactDeletedTrigger } from './contact_deleted' +export { hubspotContactMergedTrigger } from './contact_merged' export { hubspotContactPrivacyDeletedTrigger } from './contact_privacy_deleted' export { hubspotContactPropertyChangedTrigger } from './contact_property_changed' +export { hubspotContactRestoredTrigger } from './contact_restored' export { hubspotConversationCreationTrigger } from './conversation_creation' export { hubspotConversationDeletionTrigger } from './conversation_deletion' export { hubspotConversationNewMessageTrigger } from './conversation_new_message' @@ -12,8 +16,13 @@ export { hubspotConversationPrivacyDeletionTrigger } from './conversation_privac export { hubspotConversationPropertyChangedTrigger } from './conversation_property_changed' export { hubspotDealCreatedTrigger } from './deal_created' export { hubspotDealDeletedTrigger } from './deal_deleted' +export { hubspotDealMergedTrigger } from './deal_merged' export { hubspotDealPropertyChangedTrigger } from './deal_property_changed' +export { hubspotDealRestoredTrigger } from './deal_restored' export { hubspotTicketCreatedTrigger } from './ticket_created' export { hubspotTicketDeletedTrigger } from './ticket_deleted' +export { hubspotTicketMergedTrigger } from './ticket_merged' export { hubspotTicketPropertyChangedTrigger } from './ticket_property_changed' +export { hubspotTicketRestoredTrigger } from './ticket_restored' export { hubspotAllTriggerOptions, isHubSpotContactEventMatch } from './utils' +export { hubspotWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/hubspot/ticket_merged.ts b/apps/sim/triggers/hubspot/ticket_merged.ts new file mode 100644 index 00000000000..ce860ab1465 --- /dev/null +++ b/apps/sim/triggers/hubspot/ticket_merged.ts @@ -0,0 +1,217 @@ +import { HubspotIcon } from '@/components/icons' +import { + buildTicketMergedOutputs, + hubspotSetupInstructions, + hubspotTicketTriggerOptions, +} from '@/triggers/hubspot/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const hubspotTicketMergedTrigger: TriggerConfig = { + id: 'hubspot_ticket_merged', + name: 'HubSpot Ticket Merged', + provider: 'hubspot', + description: 'Trigger workflow when tickets are merged in HubSpot', + version: '1.0.0', + icon: HubspotIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: hubspotTicketTriggerOptions, + value: () => 'hubspot_ticket_merged', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client ID', + description: 'Found in your HubSpot app settings under Auth tab', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client Secret', + description: 'Found in your HubSpot app settings under Auth tab', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'Enter your HubSpot App ID', + description: 'Found in your HubSpot app settings. Used to identify your app.', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + { + id: 'developerApiKey', + title: 'Developer API Key', + type: 'short-input', + placeholder: 'Enter your HubSpot Developer API Key', + description: 'Used for making API calls to HubSpot. Found in your HubSpot app settings.', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + description: 'Copy this URL and paste it into your HubSpot app webhook subscription settings', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'hubspot_ticket_merged', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: hubspotSetupInstructions( + 'ticket.merge', + 'The webhook will trigger whenever two tickets are merged in your HubSpot account. The objectId will be the winning (primary) ticket ID.' + ), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + { + id: 'curlSetWebhookUrl', + title: '1. Set Webhook Target URL', + type: 'code', + language: 'javascript', + value: (params: Record) => { + const webhookUrl = params.webhookUrlDisplay || '{YOUR_WEBHOOK_URL_FROM_ABOVE}' + return `curl -X PUT "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/settings?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "targetUrl": "${webhookUrl}", + "throttling": { + "maxConcurrentRequests": 10 + } + }'` + }, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to set your webhook URL in HubSpot', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + { + id: 'curlCreateSubscription', + title: '2. Create Webhook Subscription', + type: 'code', + language: 'javascript', + defaultValue: `curl -X POST "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/subscriptions?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "eventType": "ticket.merge", + "active": true + }'`, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to subscribe to ticket merge events', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + [ + { + eventId: 3181526817, + subscriptionId: 4629978, + portalId: 244315265, + appId: 23608917, + occurredAt: 1762659213730, + subscriptionType: 'ticket.merge', + attemptNumber: 0, + objectId: 516126906049, + changeFlag: 'MERGED', + changeSource: 'CRM_UI', + sourceId: 'userId:84916424', + mergedObjectIds: [516126906050], + }, + ], + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_merged', + }, + }, + ], + + outputs: buildTicketMergedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-HubSpot-Signature': 'sha256=...', + 'X-HubSpot-Request-Id': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'User-Agent': 'HubSpot Webhooks', + }, + }, +} diff --git a/apps/sim/triggers/hubspot/ticket_restored.ts b/apps/sim/triggers/hubspot/ticket_restored.ts new file mode 100644 index 00000000000..028082658b4 --- /dev/null +++ b/apps/sim/triggers/hubspot/ticket_restored.ts @@ -0,0 +1,216 @@ +import { HubspotIcon } from '@/components/icons' +import { + buildTicketRestoredOutputs, + hubspotSetupInstructions, + hubspotTicketTriggerOptions, +} from '@/triggers/hubspot/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const hubspotTicketRestoredTrigger: TriggerConfig = { + id: 'hubspot_ticket_restored', + name: 'HubSpot Ticket Restored', + provider: 'hubspot', + description: 'Trigger workflow when a deleted ticket is restored in HubSpot', + version: '1.0.0', + icon: HubspotIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: hubspotTicketTriggerOptions, + value: () => 'hubspot_ticket_restored', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client ID', + description: 'Found in your HubSpot app settings under Auth tab', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client Secret', + description: 'Found in your HubSpot app settings under Auth tab', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'Enter your HubSpot App ID', + description: 'Found in your HubSpot app settings. Used to identify your app.', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + { + id: 'developerApiKey', + title: 'Developer API Key', + type: 'short-input', + placeholder: 'Enter your HubSpot Developer API Key', + description: 'Used for making API calls to HubSpot. Found in your HubSpot app settings.', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + description: 'Copy this URL and paste it into your HubSpot app webhook subscription settings', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'hubspot_ticket_restored', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: hubspotSetupInstructions( + 'ticket.restore', + 'The webhook will trigger whenever a previously deleted ticket is restored in your HubSpot account.' + ), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + { + id: 'curlSetWebhookUrl', + title: '1. Set Webhook Target URL', + type: 'code', + language: 'javascript', + value: (params: Record) => { + const webhookUrl = params.webhookUrlDisplay || '{YOUR_WEBHOOK_URL_FROM_ABOVE}' + return `curl -X PUT "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/settings?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "targetUrl": "${webhookUrl}", + "throttling": { + "maxConcurrentRequests": 10 + } + }'` + }, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to set your webhook URL in HubSpot', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + { + id: 'curlCreateSubscription', + title: '2. Create Webhook Subscription', + type: 'code', + language: 'javascript', + defaultValue: `curl -X POST "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/subscriptions?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "eventType": "ticket.restore", + "active": true + }'`, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to subscribe to ticket restore events', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + [ + { + eventId: 3181526818, + subscriptionId: 4629979, + portalId: 244315265, + appId: 23608917, + occurredAt: 1762659213730, + subscriptionType: 'ticket.restore', + attemptNumber: 0, + objectId: 516126906049, + changeFlag: 'RESTORED', + changeSource: 'CRM_UI', + sourceId: 'userId:84916424', + }, + ], + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_ticket_restored', + }, + }, + ], + + outputs: buildTicketRestoredOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-HubSpot-Signature': 'sha256=...', + 'X-HubSpot-Request-Id': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'User-Agent': 'HubSpot Webhooks', + }, + }, +} diff --git a/apps/sim/triggers/hubspot/utils.ts b/apps/sim/triggers/hubspot/utils.ts index 3a220e70389..fa601b5980a 100644 --- a/apps/sim/triggers/hubspot/utils.ts +++ b/apps/sim/triggers/hubspot/utils.ts @@ -6,11 +6,15 @@ import type { TriggerOutput } from '@/triggers/types' export const hubspotAllTriggerOptions = [ { label: 'Contact Created', id: 'hubspot_contact_created' }, { label: 'Contact Deleted', id: 'hubspot_contact_deleted' }, + { label: 'Contact Merged', id: 'hubspot_contact_merged' }, { label: 'Contact Privacy Deleted', id: 'hubspot_contact_privacy_deleted' }, { label: 'Contact Property Changed', id: 'hubspot_contact_property_changed' }, + { label: 'Contact Restored', id: 'hubspot_contact_restored' }, { label: 'Company Created', id: 'hubspot_company_created' }, { label: 'Company Deleted', id: 'hubspot_company_deleted' }, + { label: 'Company Merged', id: 'hubspot_company_merged' }, { label: 'Company Property Changed', id: 'hubspot_company_property_changed' }, + { label: 'Company Restored', id: 'hubspot_company_restored' }, { label: 'Conversation Creation', id: 'hubspot_conversation_creation' }, { label: 'Conversation Deletion', id: 'hubspot_conversation_deletion' }, { label: 'Conversation New Message', id: 'hubspot_conversation_new_message' }, @@ -18,10 +22,15 @@ export const hubspotAllTriggerOptions = [ { label: 'Conversation Property Changed', id: 'hubspot_conversation_property_changed' }, { label: 'Deal Created', id: 'hubspot_deal_created' }, { label: 'Deal Deleted', id: 'hubspot_deal_deleted' }, + { label: 'Deal Merged', id: 'hubspot_deal_merged' }, { label: 'Deal Property Changed', id: 'hubspot_deal_property_changed' }, + { label: 'Deal Restored', id: 'hubspot_deal_restored' }, { label: 'Ticket Created', id: 'hubspot_ticket_created' }, { label: 'Ticket Deleted', id: 'hubspot_ticket_deleted' }, + { label: 'Ticket Merged', id: 'hubspot_ticket_merged' }, { label: 'Ticket Property Changed', id: 'hubspot_ticket_property_changed' }, + { label: 'Ticket Restored', id: 'hubspot_ticket_restored' }, + { label: 'Generic Webhook (All Events)', id: 'hubspot_webhook' }, ] /** @@ -30,8 +39,10 @@ export const hubspotAllTriggerOptions = [ export const hubspotContactTriggerOptions = [ { label: 'Contact Created', id: 'hubspot_contact_created' }, { label: 'Contact Deleted', id: 'hubspot_contact_deleted' }, + { label: 'Contact Merged', id: 'hubspot_contact_merged' }, { label: 'Contact Privacy Deleted', id: 'hubspot_contact_privacy_deleted' }, { label: 'Contact Property Changed', id: 'hubspot_contact_property_changed' }, + { label: 'Contact Restored', id: 'hubspot_contact_restored' }, ] /** @@ -40,7 +51,9 @@ export const hubspotContactTriggerOptions = [ export const hubspotCompanyTriggerOptions = [ { label: 'Company Created', id: 'hubspot_company_created' }, { label: 'Company Deleted', id: 'hubspot_company_deleted' }, + { label: 'Company Merged', id: 'hubspot_company_merged' }, { label: 'Company Property Changed', id: 'hubspot_company_property_changed' }, + { label: 'Company Restored', id: 'hubspot_company_restored' }, ] /** @@ -60,7 +73,9 @@ export const hubspotConversationTriggerOptions = [ export const hubspotDealTriggerOptions = [ { label: 'Deal Created', id: 'hubspot_deal_created' }, { label: 'Deal Deleted', id: 'hubspot_deal_deleted' }, + { label: 'Deal Merged', id: 'hubspot_deal_merged' }, { label: 'Deal Property Changed', id: 'hubspot_deal_property_changed' }, + { label: 'Deal Restored', id: 'hubspot_deal_restored' }, ] /** @@ -69,19 +84,34 @@ export const hubspotDealTriggerOptions = [ export const hubspotTicketTriggerOptions = [ { label: 'Ticket Created', id: 'hubspot_ticket_created' }, { label: 'Ticket Deleted', id: 'hubspot_ticket_deleted' }, + { label: 'Ticket Merged', id: 'hubspot_ticket_merged' }, { label: 'Ticket Property Changed', id: 'hubspot_ticket_property_changed' }, + { label: 'Ticket Restored', id: 'hubspot_ticket_restored' }, ] +/** + * Derives the required OAuth scope from a HubSpot event type + */ +function getScopeForEventType(eventType: string): string { + if (eventType.startsWith('company')) return 'crm.objects.companies.read' + if (eventType.startsWith('deal')) return 'crm.objects.deals.read' + if (eventType.startsWith('ticket')) return 'tickets' + if (eventType === 'All Events') + return 'crm.objects.contacts.read, crm.objects.companies.read, crm.objects.deals.read, tickets' + return 'crm.objects.contacts.read' +} + /** * Generate setup instructions for a specific HubSpot event type */ export function hubspotSetupInstructions(eventType: string, additionalNotes?: string): string { + const scope = getScopeForEventType(eventType) const instructions = [ 'Step 1: Create a HubSpot Developer Account
Sign up for a free developer account at developers.hubspot.com if you don\'t have one.', 'Step 2: Create a Public App via CLI
Note: HubSpot has deprecated the web UI for creating apps. You must use the HubSpot CLI to create and manage apps. Install the CLI with npm install -g @hubspot/cli and run hs project create to create a new app. See HubSpot\'s documentation for details.', 'Step 3: Configure OAuth Settings
After creating your app via CLI, configure it to add the OAuth Redirect URL: https://www.sim.ai/api/auth/oauth2/callback/hubspot. Then retrieve your Client ID and Client Secret from your app configuration and enter them in the fields above.', "Step 4: Get App ID and Developer API Key
In your HubSpot developer account, find your App ID (shown below your app name) and your Developer API Key (in app settings). You'll need both for the next steps.", - 'Step 5: Set Required Scopes
Configure your app to include the required OAuth scope: crm.objects.contacts.read', + `Step 5: Set Required Scopes
Configure your app to include the required OAuth scope(s): ${scope}`, 'Step 6: Configure Webhook in HubSpot via API
After saving above, copy the Webhook URL and run the two curl commands below (replace {YOUR_APP_ID}, {YOUR_DEVELOPER_API_KEY}, and {YOUR_WEBHOOK_URL_FROM_ABOVE} with your actual values).', "Step 7: Test Your Webhook
Create or modify a contact in HubSpot to trigger the webhook. Check your workflow execution logs in Sim to verify it's working.", ] @@ -172,6 +202,76 @@ function buildBaseHubSpotOutputs(): Record { } as any } +/** + * Merge-specific webhook outputs that include mergedObjectIds + */ +function buildMergeHubSpotOutputs(): Record { + return { + payload: { + type: 'array', + description: 'Full webhook payload array from HubSpot containing merge event details', + items: { + type: 'object', + properties: { + objectId: { type: 'number', description: 'HubSpot object ID (winning/primary record)' }, + subscriptionType: { type: 'string', description: 'Type of subscription event' }, + portalId: { type: 'number', description: 'HubSpot portal ID' }, + occurredAt: { type: 'number', description: 'Timestamp when event occurred (ms)' }, + attemptNumber: { type: 'number', description: 'Webhook delivery attempt number' }, + eventId: { type: 'number', description: 'Event ID' }, + changeSource: { type: 'string', description: 'Source of the change' }, + mergedObjectIds: { + type: 'array', + description: 'IDs of the objects that were merged into the primary record', + }, + }, + }, + }, + provider: { + type: 'string', + description: 'Provider name (hubspot)', + }, + providerConfig: { + type: 'object', + description: 'Provider configuration', + properties: { + appId: { + type: 'string', + description: 'HubSpot App ID', + }, + clientId: { + type: 'string', + description: 'HubSpot Client ID', + }, + triggerId: { + type: 'string', + description: 'Trigger ID (e.g., hubspot_contact_merged)', + }, + clientSecret: { + type: 'string', + description: 'HubSpot Client Secret', + }, + developerApiKey: { + type: 'string', + description: 'HubSpot Developer API Key', + }, + curlSetWebhookUrl: { + type: 'string', + description: 'curl command to set webhook URL', + }, + curlCreateSubscription: { + type: 'string', + description: 'curl command to create subscription', + }, + webhookUrlDisplay: { + type: 'string', + description: 'Webhook URL display value', + }, + }, + }, + } as any +} + /** * Build output schema for contact creation events */ @@ -200,6 +300,20 @@ export function buildContactPropertyChangedOutputs(): Record { + return buildMergeHubSpotOutputs() +} + +/** + * Build output schema for contact restore events + */ +export function buildContactRestoredOutputs(): Record { + return buildBaseHubSpotOutputs() +} + /** * Build output schema for company creation events */ @@ -221,6 +335,20 @@ export function buildCompanyPropertyChangedOutputs(): Record { + return buildMergeHubSpotOutputs() +} + +/** + * Build output schema for company restore events + */ +export function buildCompanyRestoredOutputs(): Record { + return buildBaseHubSpotOutputs() +} + /** * Build output schema for conversation creation events */ @@ -277,6 +405,20 @@ export function buildDealPropertyChangedOutputs(): Record return buildBaseHubSpotOutputs() } +/** + * Build output schema for deal merge events + */ +export function buildDealMergedOutputs(): Record { + return buildMergeHubSpotOutputs() +} + +/** + * Build output schema for deal restore events + */ +export function buildDealRestoredOutputs(): Record { + return buildBaseHubSpotOutputs() +} + /** * Build output schema for ticket creation events */ @@ -298,6 +440,27 @@ export function buildTicketPropertyChangedOutputs(): Record { + return buildMergeHubSpotOutputs() +} + +/** + * Build output schema for ticket restore events + */ +export function buildTicketRestoredOutputs(): Record { + return buildBaseHubSpotOutputs() +} + +/** + * Build output schema for generic webhook events + */ +export function buildWebhookOutputs(): Record { + return buildBaseHubSpotOutputs() +} + /** * Check if a HubSpot event matches the expected trigger configuration */ @@ -305,11 +468,15 @@ export function isHubSpotContactEventMatch(triggerId: string, eventType: string) const eventMap: Record = { hubspot_contact_created: 'contact.creation', hubspot_contact_deleted: 'contact.deletion', + hubspot_contact_merged: 'contact.merge', hubspot_contact_privacy_deleted: 'contact.privacyDeletion', hubspot_contact_property_changed: 'contact.propertyChange', + hubspot_contact_restored: 'contact.restore', hubspot_company_created: 'company.creation', hubspot_company_deleted: 'company.deletion', + hubspot_company_merged: 'company.merge', hubspot_company_property_changed: 'company.propertyChange', + hubspot_company_restored: 'company.restore', hubspot_conversation_creation: 'conversation.creation', hubspot_conversation_deletion: 'conversation.deletion', hubspot_conversation_new_message: 'conversation.newMessage', @@ -317,10 +484,14 @@ export function isHubSpotContactEventMatch(triggerId: string, eventType: string) hubspot_conversation_property_changed: 'conversation.propertyChange', hubspot_deal_created: 'deal.creation', hubspot_deal_deleted: 'deal.deletion', + hubspot_deal_merged: 'deal.merge', hubspot_deal_property_changed: 'deal.propertyChange', + hubspot_deal_restored: 'deal.restore', hubspot_ticket_created: 'ticket.creation', hubspot_ticket_deleted: 'ticket.deletion', + hubspot_ticket_merged: 'ticket.merge', hubspot_ticket_property_changed: 'ticket.propertyChange', + hubspot_ticket_restored: 'ticket.restore', } const expectedEventType = eventMap[triggerId] diff --git a/apps/sim/triggers/hubspot/webhook.ts b/apps/sim/triggers/hubspot/webhook.ts new file mode 100644 index 00000000000..addcb92b923 --- /dev/null +++ b/apps/sim/triggers/hubspot/webhook.ts @@ -0,0 +1,223 @@ +import { HubspotIcon } from '@/components/icons' +import { + buildWebhookOutputs, + hubspotAllTriggerOptions, + hubspotSetupInstructions, +} from '@/triggers/hubspot/utils' +import type { TriggerConfig } from '@/triggers/types' + +export const hubspotWebhookTrigger: TriggerConfig = { + id: 'hubspot_webhook', + name: 'HubSpot Webhook (All Events)', + provider: 'hubspot', + description: 'Trigger workflow on any HubSpot webhook event', + version: '1.0.0', + icon: HubspotIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: hubspotAllTriggerOptions, + value: () => 'hubspot_webhook', + required: true, + }, + { + id: 'clientId', + title: 'Client ID', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client ID', + description: 'Found in your HubSpot app settings under Auth tab', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + { + id: 'clientSecret', + title: 'Client Secret', + type: 'short-input', + placeholder: 'Enter your HubSpot app Client Secret', + description: 'Found in your HubSpot app settings under Auth tab', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + { + id: 'appId', + title: 'App ID', + type: 'short-input', + placeholder: 'Enter your HubSpot App ID', + description: 'Found in your HubSpot app settings. Used to identify your app.', + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + { + id: 'developerApiKey', + title: 'Developer API Key', + type: 'short-input', + placeholder: 'Enter your HubSpot Developer API Key', + description: 'Used for making API calls to HubSpot. Found in your HubSpot app settings.', + password: true, + required: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + description: 'Copy this URL and paste it into your HubSpot app webhook subscription settings', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + mode: 'trigger', + triggerId: 'hubspot_webhook', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + type: 'text', + defaultValue: hubspotSetupInstructions( + 'All Events', + 'This generic webhook trigger will accept all HubSpot webhook events. Create subscriptions for each event type you want to receive using the curl command below (changing the eventType parameter).' + ), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + { + id: 'curlSetWebhookUrl', + title: '1. Set Webhook Target URL', + type: 'code', + language: 'javascript', + value: (params: Record) => { + const webhookUrl = params.webhookUrlDisplay || '{YOUR_WEBHOOK_URL_FROM_ABOVE}' + return `curl -X PUT "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/settings?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "targetUrl": "${webhookUrl}", + "throttling": { + "maxConcurrentRequests": 10 + } + }'` + }, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command to set your webhook URL in HubSpot', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + { + id: 'curlCreateSubscription', + title: '2. Create Webhook Subscription', + type: 'code', + language: 'javascript', + defaultValue: `# Create subscriptions for each event type you want to receive. +# Replace {eventType} with: contact.creation, contact.deletion, contact.propertyChange, +# contact.merge, contact.restore, company.creation, company.deletion, company.propertyChange, +# company.merge, company.restore, deal.creation, deal.deletion, deal.propertyChange, +# deal.merge, deal.restore, ticket.creation, ticket.deletion, ticket.propertyChange, +# ticket.merge, ticket.restore, etc. + +curl -X POST "https://api.hubapi.com/webhooks/v3/{YOUR_APP_ID}/subscriptions?hapikey={YOUR_DEVELOPER_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '{ + "eventType": "{eventType}", + "active": true + }'`, + readOnly: true, + collapsible: true, + defaultCollapsed: true, + showCopyButton: true, + description: 'Run this command for each event type you want to subscribe to', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + { + id: 'samplePayload', + title: 'Event Payload Example', + type: 'code', + language: 'json', + defaultValue: JSON.stringify( + [ + { + eventId: 3181526809, + subscriptionId: 4629970, + portalId: 244315265, + appId: 23608917, + occurredAt: 1762659213730, + subscriptionType: 'contact.creation', + attemptNumber: 0, + objectId: 316126906049, + changeFlag: 'CREATED', + changeSource: 'CRM_UI', + sourceId: 'userId:84916424', + }, + ], + null, + 2 + ), + readOnly: true, + collapsible: true, + defaultCollapsed: true, + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'hubspot_webhook', + }, + }, + ], + + outputs: buildWebhookOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-HubSpot-Signature': 'sha256=...', + 'X-HubSpot-Request-Id': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', + 'User-Agent': 'HubSpot Webhooks', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 9671a62c9f7..3c1845a7fd0 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -103,11 +103,15 @@ import { import { hubspotCompanyCreatedTrigger, hubspotCompanyDeletedTrigger, + hubspotCompanyMergedTrigger, hubspotCompanyPropertyChangedTrigger, + hubspotCompanyRestoredTrigger, hubspotContactCreatedTrigger, hubspotContactDeletedTrigger, + hubspotContactMergedTrigger, hubspotContactPrivacyDeletedTrigger, hubspotContactPropertyChangedTrigger, + hubspotContactRestoredTrigger, hubspotConversationCreationTrigger, hubspotConversationDeletionTrigger, hubspotConversationNewMessageTrigger, @@ -115,10 +119,15 @@ import { hubspotConversationPropertyChangedTrigger, hubspotDealCreatedTrigger, hubspotDealDeletedTrigger, + hubspotDealMergedTrigger, hubspotDealPropertyChangedTrigger, + hubspotDealRestoredTrigger, hubspotTicketCreatedTrigger, hubspotTicketDeletedTrigger, + hubspotTicketMergedTrigger, hubspotTicketPropertyChangedTrigger, + hubspotTicketRestoredTrigger, + hubspotWebhookTrigger, } from '@/triggers/hubspot' import { imapPollingTrigger } from '@/triggers/imap' import { @@ -325,11 +334,15 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { webflow_form_submission: webflowFormSubmissionTrigger, hubspot_contact_created: hubspotContactCreatedTrigger, hubspot_contact_deleted: hubspotContactDeletedTrigger, + hubspot_contact_merged: hubspotContactMergedTrigger, hubspot_contact_privacy_deleted: hubspotContactPrivacyDeletedTrigger, hubspot_contact_property_changed: hubspotContactPropertyChangedTrigger, + hubspot_contact_restored: hubspotContactRestoredTrigger, hubspot_company_created: hubspotCompanyCreatedTrigger, hubspot_company_deleted: hubspotCompanyDeletedTrigger, + hubspot_company_merged: hubspotCompanyMergedTrigger, hubspot_company_property_changed: hubspotCompanyPropertyChangedTrigger, + hubspot_company_restored: hubspotCompanyRestoredTrigger, hubspot_conversation_creation: hubspotConversationCreationTrigger, hubspot_conversation_deletion: hubspotConversationDeletionTrigger, hubspot_conversation_new_message: hubspotConversationNewMessageTrigger, @@ -337,9 +350,14 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { hubspot_conversation_property_changed: hubspotConversationPropertyChangedTrigger, hubspot_deal_created: hubspotDealCreatedTrigger, hubspot_deal_deleted: hubspotDealDeletedTrigger, + hubspot_deal_merged: hubspotDealMergedTrigger, hubspot_deal_property_changed: hubspotDealPropertyChangedTrigger, + hubspot_deal_restored: hubspotDealRestoredTrigger, hubspot_ticket_created: hubspotTicketCreatedTrigger, hubspot_ticket_deleted: hubspotTicketDeletedTrigger, + hubspot_ticket_merged: hubspotTicketMergedTrigger, hubspot_ticket_property_changed: hubspotTicketPropertyChangedTrigger, + hubspot_ticket_restored: hubspotTicketRestoredTrigger, + hubspot_webhook: hubspotWebhookTrigger, imap_poller: imapPollingTrigger, } From 62a7700eb98aa1e934be8c370adc2be327ec2e25 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 11:25:37 -0700 Subject: [PATCH 04/32] feat(integrations): add Sixtyfour AI integration (#3981) * feat(integrations): add Sixtyfour AI integration Add Sixtyfour AI integration with 4 tools: find_phone, find_email, enrich_lead, enrich_company. Includes block with operation dropdown, API key auth, conditional fields per operation, brand icon, and generated docs. * fix(integrations): add error handling to sixtyfour tools Wrap JSON.parse calls in try/catch for enrich_lead and enrich_company. Add response.ok checks to all 4 tools' transformResponse. * fix(integrations): use typed Record for leadStruct to fix spread type error Co-Authored-By: Claude Opus 4.6 * docs * airweave docslink * turbo update * more inp/outputs --------- Co-authored-by: Claude Opus 4.6 --- apps/docs/components/icons.tsx | 37 ++- apps/docs/components/ui/icon-mapping.ts | 2 + apps/docs/content/docs/en/tools/meta.json | 1 + apps/docs/content/docs/en/tools/sixtyfour.mdx | 128 ++++++++ .../integrations/data/icon-mapping.ts | 2 + .../integrations/data/integrations.json | 37 ++- apps/sim/blocks/blocks/airweave.ts | 2 +- apps/sim/blocks/blocks/sixtyfour.ts | 292 ++++++++++++++++++ apps/sim/blocks/registry.ts | 2 + apps/sim/components/icons.tsx | 37 ++- apps/sim/tools/registry.ts | 10 + apps/sim/tools/sixtyfour/enrich_company.ts | 143 +++++++++ apps/sim/tools/sixtyfour/enrich_lead.ts | 105 +++++++ apps/sim/tools/sixtyfour/find_email.ts | 144 +++++++++ apps/sim/tools/sixtyfour/find_phone.ts | 100 ++++++ apps/sim/tools/sixtyfour/index.ts | 4 + apps/sim/tools/sixtyfour/types.ts | 78 +++++ turbo.json | 2 +- 18 files changed, 1121 insertions(+), 5 deletions(-) create mode 100644 apps/docs/content/docs/en/tools/sixtyfour.mdx create mode 100644 apps/sim/blocks/blocks/sixtyfour.ts create mode 100644 apps/sim/tools/sixtyfour/enrich_company.ts create mode 100644 apps/sim/tools/sixtyfour/enrich_lead.ts create mode 100644 apps/sim/tools/sixtyfour/find_email.ts create mode 100644 apps/sim/tools/sixtyfour/find_phone.ts create mode 100644 apps/sim/tools/sixtyfour/index.ts create mode 100644 apps/sim/tools/sixtyfour/types.ts diff --git a/apps/docs/components/icons.tsx b/apps/docs/components/icons.tsx index 6a2a66183fb..cbc3b5edd85 100644 --- a/apps/docs/components/icons.tsx +++ b/apps/docs/components/icons.tsx @@ -2132,7 +2132,15 @@ export function Mem0Icon(props: SVGProps) { export function ExtendIcon(props: SVGProps) { return ( - + + + ) { ) } +export function SixtyfourIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function SimilarwebIcon(props: SVGProps) { return ( = { sharepoint: MicrosoftSharepointIcon, shopify: ShopifyIcon, similarweb: SimilarwebIcon, + sixtyfour: SixtyfourIcon, slack: SlackIcon, smtp: SmtpIcon, sqs: SQSIcon, diff --git a/apps/docs/content/docs/en/tools/meta.json b/apps/docs/content/docs/en/tools/meta.json index cc194da1f25..a0f99bf6616 100644 --- a/apps/docs/content/docs/en/tools/meta.json +++ b/apps/docs/content/docs/en/tools/meta.json @@ -150,6 +150,7 @@ "sharepoint", "shopify", "similarweb", + "sixtyfour", "slack", "smtp", "sqs", diff --git a/apps/docs/content/docs/en/tools/sixtyfour.mdx b/apps/docs/content/docs/en/tools/sixtyfour.mdx new file mode 100644 index 00000000000..41a55c58463 --- /dev/null +++ b/apps/docs/content/docs/en/tools/sixtyfour.mdx @@ -0,0 +1,128 @@ +--- +title: Sixtyfour AI +description: Enrich leads and companies with AI-powered research +--- + +import { BlockInfoCard } from "@/components/ui/block-info-card" + + + +## Usage Instructions + +Find emails, phone numbers, and enrich lead or company data with contact information, social profiles, and detailed research using Sixtyfour AI. + + + +## Tools + +### `sixtyfour_find_phone` + +Find phone numbers for a lead using Sixtyfour AI. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sixtyfour API key | +| `name` | string | Yes | Full name of the person | +| `company` | string | No | Company name | +| `linkedinUrl` | string | No | LinkedIn profile URL | +| `domain` | string | No | Company website domain | +| `email` | string | No | Email address | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the person | +| `company` | string | Company name | +| `phone` | string | Phone number\(s\) found | +| `linkedinUrl` | string | LinkedIn profile URL | + +### `sixtyfour_find_email` + +Find email addresses for a lead using Sixtyfour AI. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sixtyfour API key | +| `name` | string | Yes | Full name of the person | +| `company` | string | No | Company name | +| `linkedinUrl` | string | No | LinkedIn profile URL | +| `domain` | string | No | Company website domain | +| `phone` | string | No | Phone number | +| `title` | string | No | Job title | +| `mode` | string | No | Email discovery mode: PROFESSIONAL \(default\) or PERSONAL | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | Name of the person | +| `company` | string | Company name | +| `title` | string | Job title | +| `phone` | string | Phone number | +| `linkedinUrl` | string | LinkedIn profile URL | +| `emails` | json | Professional email addresses found | +| ↳ `address` | string | Email address | +| ↳ `status` | string | Validation status \(OK or UNKNOWN\) | +| ↳ `type` | string | Email type \(COMPANY or PERSONAL\) | +| `personalEmails` | json | Personal email addresses found \(only in PERSONAL mode\) | +| ↳ `address` | string | Email address | +| ↳ `status` | string | Validation status \(OK or UNKNOWN\) | +| ↳ `type` | string | Email type \(COMPANY or PERSONAL\) | + +### `sixtyfour_enrich_lead` + +Enrich lead information with contact details, social profiles, and company data using Sixtyfour AI. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sixtyfour API key | +| `leadInfo` | string | Yes | Lead information as JSON object with key-value pairs \(e.g. name, company, title, linkedin\) | +| `struct` | string | Yes | Fields to collect as JSON object. Keys are field names, values are descriptions \(e.g. \{"email": "The individual\'s email address", "phone": "Phone number"\}\) | +| `researchPlan` | string | No | Optional research plan to guide enrichment strategy | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notes` | string | Research notes about the lead | +| `structuredData` | json | Enriched lead data matching the requested struct fields | +| `references` | json | Source URLs and descriptions used for enrichment | +| `confidenceScore` | number | Quality score for the returned data \(0-10\) | + +### `sixtyfour_enrich_company` + +Enrich company data with additional information and find associated people using Sixtyfour AI. + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `apiKey` | string | Yes | Sixtyfour API key | +| `targetCompany` | string | Yes | Company data as JSON object \(e.g. \{"name": "Acme Inc", "domain": "acme.com"\}\) | +| `struct` | string | Yes | Fields to collect as JSON object. Keys are field names, values are descriptions \(e.g. \{"website": "Company website URL", "num_employees": "Employee count"\}\) | +| `findPeople` | boolean | No | Whether to find people associated with the company | +| `fullOrgChart` | boolean | No | Whether to retrieve the full organizational chart | +| `researchPlan` | string | No | Optional strategy describing how the agent should search for information | +| `peopleFocusPrompt` | string | No | Description of people to find \(roles, responsibilities\) | +| `leadStruct` | string | No | Custom schema for returned lead data as JSON object | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `notes` | string | Research notes about the company | +| `structuredData` | json | Enriched company data matching the requested struct fields | +| `references` | json | Source URLs and descriptions used for enrichment | +| `confidenceScore` | number | Quality score for the returned data \(0-10\) | + + diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts index ee5f8c95a5b..603fecd3633 100644 --- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts +++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts @@ -154,6 +154,7 @@ import { SftpIcon, ShopifyIcon, SimilarwebIcon, + SixtyfourIcon, SlackIcon, SmtpIcon, SQSIcon, @@ -340,6 +341,7 @@ export const blockTypeToIconMap: Record = { sharepoint: MicrosoftSharepointIcon, shopify: ShopifyIcon, similarweb: SimilarwebIcon, + sixtyfour: SixtyfourIcon, slack: SlackIcon, smtp: SmtpIcon, sqs: SQSIcon, diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 9db82b6d349..55ab9caad0d 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -324,7 +324,7 @@ "longDescription": "Search across your synced data sources using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.", "bgColor": "#6366F1", "iconName": "AirweaveIcon", - "docsUrl": "https://docs.airweave.ai", + "docsUrl": "https://docs.sim.ai/tools/airweave", "operations": [], "operationCount": 0, "triggers": [], @@ -10639,6 +10639,41 @@ "integrationType": "analytics", "tags": ["marketing", "data-analytics", "seo"] }, + { + "type": "sixtyfour", + "slug": "sixtyfour-ai", + "name": "Sixtyfour AI", + "description": "Enrich leads and companies with AI-powered research", + "longDescription": "Find emails, phone numbers, and enrich lead or company data with contact information, social profiles, and detailed research using Sixtyfour AI.", + "bgColor": "#000000", + "iconName": "SixtyfourIcon", + "docsUrl": "https://docs.sim.ai/tools/sixtyfour", + "operations": [ + { + "name": "Find Phone", + "description": "Find phone numbers for a lead using Sixtyfour AI." + }, + { + "name": "Find Email", + "description": "Find email addresses for a lead using Sixtyfour AI." + }, + { + "name": "Enrich Lead", + "description": "Enrich lead information with contact details, social profiles, and company data using Sixtyfour AI." + }, + { + "name": "Enrich Company", + "description": "Enrich company data with additional information and find associated people using Sixtyfour AI." + } + ], + "operationCount": 4, + "triggers": [], + "triggerCount": 0, + "authType": "api-key", + "category": "tools", + "integrationType": "sales-intelligence", + "tags": ["enrichment", "sales-engagement"] + }, { "type": "slack", "slug": "slack", diff --git a/apps/sim/blocks/blocks/airweave.ts b/apps/sim/blocks/blocks/airweave.ts index caa9c4097f6..6948351d9a6 100644 --- a/apps/sim/blocks/blocks/airweave.ts +++ b/apps/sim/blocks/blocks/airweave.ts @@ -10,7 +10,7 @@ export const AirweaveBlock: BlockConfig = { authMode: AuthMode.ApiKey, longDescription: 'Search across your synced data sources using Airweave. Supports semantic search with hybrid, neural, or keyword retrieval strategies. Optionally generate AI-powered answers from search results.', - docsLink: 'https://docs.airweave.ai', + docsLink: 'https://docs.sim.ai/tools/airweave', category: 'tools', integrationType: IntegrationType.Search, tags: ['vector-search', 'knowledge-base'], diff --git a/apps/sim/blocks/blocks/sixtyfour.ts b/apps/sim/blocks/blocks/sixtyfour.ts new file mode 100644 index 00000000000..30389839a86 --- /dev/null +++ b/apps/sim/blocks/blocks/sixtyfour.ts @@ -0,0 +1,292 @@ +import { SixtyfourIcon } from '@/components/icons' +import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types' + +export const SixtyfourBlock: BlockConfig = { + type: 'sixtyfour', + name: 'Sixtyfour AI', + description: 'Enrich leads and companies with AI-powered research', + longDescription: + 'Find emails, phone numbers, and enrich lead or company data with contact information, social profiles, and detailed research using Sixtyfour AI.', + docsLink: 'https://docs.sim.ai/tools/sixtyfour', + category: 'tools', + integrationType: IntegrationType.SalesIntelligence, + tags: ['enrichment', 'sales-engagement'], + bgColor: '#000000', + icon: SixtyfourIcon, + authMode: AuthMode.ApiKey, + + subBlocks: [ + { + id: 'operation', + title: 'Operation', + type: 'dropdown', + options: [ + { label: 'Find Phone', id: 'find_phone' }, + { label: 'Find Email', id: 'find_email' }, + { label: 'Enrich Lead', id: 'enrich_lead' }, + { label: 'Enrich Company', id: 'enrich_company' }, + ], + value: () => 'find_phone', + }, + { + id: 'apiKey', + title: 'API Key', + type: 'short-input', + required: true, + placeholder: 'Enter your Sixtyfour API key', + password: true, + }, + { + id: 'name', + title: 'Name', + type: 'short-input', + placeholder: 'Full name of the person', + required: { field: 'operation', value: ['find_phone', 'find_email'] }, + condition: { field: 'operation', value: ['find_phone', 'find_email'] }, + }, + { + id: 'company', + title: 'Company', + type: 'short-input', + placeholder: 'Company name', + condition: { field: 'operation', value: ['find_phone', 'find_email'] }, + }, + { + id: 'linkedinUrl', + title: 'LinkedIn URL', + type: 'short-input', + placeholder: 'https://linkedin.com/in/johndoe', + condition: { field: 'operation', value: ['find_phone', 'find_email'] }, + mode: 'advanced', + }, + { + id: 'domain', + title: 'Domain', + type: 'short-input', + placeholder: 'example.com', + condition: { field: 'operation', value: ['find_phone', 'find_email'] }, + mode: 'advanced', + }, + { + id: 'emailInput', + title: 'Email', + type: 'short-input', + placeholder: 'Email address', + condition: { field: 'operation', value: 'find_phone' }, + mode: 'advanced', + }, + { + id: 'phoneInput', + title: 'Phone', + type: 'short-input', + placeholder: 'Phone number', + condition: { field: 'operation', value: 'find_email' }, + mode: 'advanced', + }, + { + id: 'title', + title: 'Job Title', + type: 'short-input', + placeholder: 'Job title', + condition: { field: 'operation', value: 'find_email' }, + mode: 'advanced', + }, + { + id: 'mode', + title: 'Mode', + type: 'dropdown', + options: [ + { label: 'Professional', id: 'PROFESSIONAL' }, + { label: 'Personal', id: 'PERSONAL' }, + ], + value: () => 'PROFESSIONAL', + condition: { field: 'operation', value: 'find_email' }, + }, + { + id: 'leadInfo', + title: 'Lead Info', + type: 'long-input', + placeholder: + '{"name": "John Doe", "company": "Acme Inc", "title": "CEO", "linkedin": "https://linkedin.com/in/johndoe"}', + required: { field: 'operation', value: 'enrich_lead' }, + condition: { field: 'operation', value: 'enrich_lead' }, + }, + { + id: 'leadStruct', + title: 'Fields to Collect', + type: 'long-input', + placeholder: + '{"email": "Email address", "phone": "Phone number", "company": "Company name", "title": "Job title"}', + required: { field: 'operation', value: 'enrich_lead' }, + condition: { field: 'operation', value: 'enrich_lead' }, + }, + { + id: 'leadResearchPlan', + title: 'Research Plan', + type: 'long-input', + placeholder: 'Optional guidance for the enrichment agent', + condition: { field: 'operation', value: 'enrich_lead' }, + mode: 'advanced', + }, + { + id: 'targetCompany', + title: 'Company Info', + type: 'long-input', + placeholder: '{"name": "Acme Inc", "domain": "acme.com", "industry": "Technology"}', + required: { field: 'operation', value: 'enrich_company' }, + condition: { field: 'operation', value: 'enrich_company' }, + }, + { + id: 'companyStruct', + title: 'Fields to Collect', + type: 'long-input', + placeholder: + '{"website": "Company website URL", "num_employees": "Employee count", "address": "Company address"}', + required: { field: 'operation', value: 'enrich_company' }, + condition: { field: 'operation', value: 'enrich_company' }, + }, + { + id: 'findPeople', + title: 'Find People', + type: 'switch', + condition: { field: 'operation', value: 'enrich_company' }, + }, + { + id: 'peopleFocusPrompt', + title: 'People Focus', + type: 'short-input', + placeholder: 'e.g. Find the VP of Marketing and the CTO', + condition: { field: 'operation', value: 'enrich_company' }, + mode: 'advanced', + }, + { + id: 'fullOrgChart', + title: 'Full Org Chart', + type: 'switch', + condition: { field: 'operation', value: 'enrich_company' }, + mode: 'advanced', + }, + { + id: 'companyLeadStruct', + title: 'Lead Schema', + type: 'long-input', + placeholder: '{"name": "Full name", "email": "Email", "title": "Job title"}', + condition: { field: 'operation', value: 'enrich_company' }, + mode: 'advanced', + }, + { + id: 'companyResearchPlan', + title: 'Research Plan', + type: 'long-input', + placeholder: 'Optional guidance for the enrichment agent', + condition: { field: 'operation', value: 'enrich_company' }, + mode: 'advanced', + }, + ], + + tools: { + access: [ + 'sixtyfour_find_phone', + 'sixtyfour_find_email', + 'sixtyfour_enrich_lead', + 'sixtyfour_enrich_company', + ], + config: { + tool: (params) => `sixtyfour_${params.operation}`, + params: (params) => { + const result: Record = {} + + if (params.operation === 'find_phone') { + if (params.emailInput) result.email = params.emailInput + } else if (params.operation === 'find_email') { + if (params.phoneInput) result.phone = params.phoneInput + } else if (params.operation === 'enrich_lead') { + result.leadInfo = params.leadInfo + result.struct = params.leadStruct + if (params.leadResearchPlan) result.researchPlan = params.leadResearchPlan + } else if (params.operation === 'enrich_company') { + result.targetCompany = params.targetCompany + result.struct = params.companyStruct + if (params.findPeople !== undefined) result.findPeople = Boolean(params.findPeople) + if (params.fullOrgChart !== undefined) result.fullOrgChart = Boolean(params.fullOrgChart) + if (params.peopleFocusPrompt) result.peopleFocusPrompt = params.peopleFocusPrompt + if (params.companyLeadStruct) result.leadStruct = params.companyLeadStruct + if (params.companyResearchPlan) result.researchPlan = params.companyResearchPlan + } + + return result + }, + }, + }, + + inputs: { + operation: { type: 'string', description: 'Operation to perform' }, + apiKey: { type: 'string', description: 'Sixtyfour API key' }, + name: { type: 'string', description: 'Person name' }, + company: { type: 'string', description: 'Company name' }, + linkedinUrl: { type: 'string', description: 'LinkedIn URL' }, + domain: { type: 'string', description: 'Company domain' }, + emailInput: { type: 'string', description: 'Email address (find phone)' }, + phoneInput: { type: 'string', description: 'Phone number (find email)' }, + title: { type: 'string', description: 'Job title' }, + mode: { type: 'string', description: 'Email mode (PROFESSIONAL or PERSONAL)' }, + leadInfo: { type: 'string', description: 'Lead information JSON' }, + leadStruct: { type: 'string', description: 'Fields to collect for lead' }, + leadResearchPlan: { type: 'string', description: 'Research plan for lead enrichment' }, + targetCompany: { type: 'string', description: 'Company information JSON' }, + companyStruct: { type: 'string', description: 'Fields to collect for company' }, + findPeople: { type: 'boolean', description: 'Find associated people' }, + fullOrgChart: { type: 'boolean', description: 'Retrieve full org chart' }, + peopleFocusPrompt: { type: 'string', description: 'People focus description' }, + companyLeadStruct: { type: 'string', description: 'Lead schema for company enrichment' }, + companyResearchPlan: { type: 'string', description: 'Research plan for company enrichment' }, + }, + + outputs: { + name: { + type: 'string', + description: 'Name of the person (find_phone, find_email)', + }, + company: { + type: 'string', + description: 'Company name (find_phone, find_email)', + }, + phone: { + type: 'string', + description: 'Phone number(s) found (find_phone)', + }, + linkedinUrl: { + type: 'string', + description: 'LinkedIn profile URL (find_phone, find_email)', + }, + title: { + type: 'string', + description: 'Job title (find_email)', + }, + emails: { + type: 'json', + description: 'Email addresses found with validation status and type (find_email)', + }, + personalEmails: { + type: 'json', + description: 'Personal email addresses found in PERSONAL mode (find_email)', + }, + notes: { + type: 'string', + description: 'Research notes (enrich_lead, enrich_company)', + }, + structuredData: { + type: 'json', + description: + 'Enriched data matching the requested struct fields (enrich_lead, enrich_company)', + }, + references: { + type: 'json', + description: 'Source URLs and descriptions used for enrichment (enrich_lead, enrich_company)', + }, + confidenceScore: { + type: 'number', + description: 'Quality score for the returned data, 0-10 (enrich_lead, enrich_company)', + }, + }, +} diff --git a/apps/sim/blocks/registry.ts b/apps/sim/blocks/registry.ts index 8b21cebea1a..59a385df18c 100644 --- a/apps/sim/blocks/registry.ts +++ b/apps/sim/blocks/registry.ts @@ -171,6 +171,7 @@ import { SftpBlock } from '@/blocks/blocks/sftp' import { SharepointBlock } from '@/blocks/blocks/sharepoint' import { ShopifyBlock } from '@/blocks/blocks/shopify' import { SimilarwebBlock } from '@/blocks/blocks/similarweb' +import { SixtyfourBlock } from '@/blocks/blocks/sixtyfour' import { SlackBlock } from '@/blocks/blocks/slack' import { SmtpBlock } from '@/blocks/blocks/smtp' import { SpotifyBlock } from '@/blocks/blocks/spotify' @@ -407,6 +408,7 @@ export const registry: Record = { sharepoint: SharepointBlock, shopify: ShopifyBlock, similarweb: SimilarwebBlock, + sixtyfour: SixtyfourBlock, slack: SlackBlock, smtp: SmtpBlock, spotify: SpotifyBlock, diff --git a/apps/sim/components/icons.tsx b/apps/sim/components/icons.tsx index 6a2a66183fb..cbc3b5edd85 100644 --- a/apps/sim/components/icons.tsx +++ b/apps/sim/components/icons.tsx @@ -2132,7 +2132,15 @@ export function Mem0Icon(props: SVGProps) { export function ExtendIcon(props: SVGProps) { return ( - + + + ) { ) } +export function SixtyfourIcon(props: SVGProps) { + return ( + + + + + + + + ) +} + export function SimilarwebIcon(props: SVGProps) { return ( = { servicenow_read_record: servicenowReadRecordTool, servicenow_update_record: servicenowUpdateRecordTool, servicenow_delete_record: servicenowDeleteRecordTool, + sixtyfour_find_phone: sixtyfourFindPhoneTool, + sixtyfour_find_email: sixtyfourFindEmailTool, + sixtyfour_enrich_lead: sixtyfourEnrichLeadTool, + sixtyfour_enrich_company: sixtyfourEnrichCompanyTool, tavily_search: tavilySearchTool, tavily_extract: tavilyExtractTool, tavily_crawl: tavilyCrawlTool, diff --git a/apps/sim/tools/sixtyfour/enrich_company.ts b/apps/sim/tools/sixtyfour/enrich_company.ts new file mode 100644 index 00000000000..b296dbca1fb --- /dev/null +++ b/apps/sim/tools/sixtyfour/enrich_company.ts @@ -0,0 +1,143 @@ +import type { + SixtyfourEnrichCompanyParams, + SixtyfourEnrichCompanyResponse, +} from '@/tools/sixtyfour/types' +import type { ToolConfig } from '@/tools/types' + +export const sixtyfourEnrichCompanyTool: ToolConfig< + SixtyfourEnrichCompanyParams, + SixtyfourEnrichCompanyResponse +> = { + id: 'sixtyfour_enrich_company', + name: 'Sixtyfour Enrich Company', + description: + 'Enrich company data with additional information and find associated people using Sixtyfour AI.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sixtyfour API key', + }, + targetCompany: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Company data as JSON object (e.g. {"name": "Acme Inc", "domain": "acme.com"})', + }, + struct: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Fields to collect as JSON object. Keys are field names, values are descriptions (e.g. {"website": "Company website URL", "num_employees": "Employee count"})', + }, + findPeople: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to find people associated with the company', + }, + fullOrgChart: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether to retrieve the full organizational chart', + }, + researchPlan: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional strategy describing how the agent should search for information', + }, + peopleFocusPrompt: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Description of people to find (roles, responsibilities)', + }, + leadStruct: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Custom schema for returned lead data as JSON object', + }, + }, + + request: { + url: 'https://api.sixtyfour.ai/enrich-company', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'x-api-key': params.apiKey, + }), + body: (params) => { + let targetCompany: unknown + try { + targetCompany = + typeof params.targetCompany === 'string' + ? JSON.parse(params.targetCompany) + : params.targetCompany + } catch { + throw new Error('targetCompany must be valid JSON') + } + let struct: unknown + try { + struct = typeof params.struct === 'string' ? JSON.parse(params.struct) : params.struct + } catch { + throw new Error('struct must be valid JSON') + } + let leadStruct: Record | undefined + try { + leadStruct = + params.leadStruct && typeof params.leadStruct === 'string' + ? (JSON.parse(params.leadStruct) as Record) + : (params.leadStruct as Record | undefined) + } catch { + throw new Error('leadStruct must be valid JSON') + } + return { + target_company: targetCompany, + struct, + ...(params.findPeople !== undefined && { find_people: params.findPeople }), + ...(params.fullOrgChart !== undefined && { full_org_chart: params.fullOrgChart }), + ...(params.researchPlan && { research_plan: params.researchPlan }), + ...(params.peopleFocusPrompt && { people_focus_prompt: params.peopleFocusPrompt }), + ...(leadStruct && { lead_struct: leadStruct }), + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || data.message || data.detail || `API error: ${response.status}`) + } + + return { + success: true, + output: { + notes: data.notes ?? null, + structuredData: data.structured_data ?? {}, + references: data.references ?? {}, + confidenceScore: data.confidence_score ?? null, + }, + } + }, + + outputs: { + notes: { type: 'string', description: 'Research notes about the company', optional: true }, + structuredData: { + type: 'json', + description: 'Enriched company data matching the requested struct fields', + }, + references: { type: 'json', description: 'Source URLs and descriptions used for enrichment' }, + confidenceScore: { + type: 'number', + description: 'Quality score for the returned data (0-10)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/sixtyfour/enrich_lead.ts b/apps/sim/tools/sixtyfour/enrich_lead.ts new file mode 100644 index 00000000000..424a69a383b --- /dev/null +++ b/apps/sim/tools/sixtyfour/enrich_lead.ts @@ -0,0 +1,105 @@ +import type { + SixtyfourEnrichLeadParams, + SixtyfourEnrichLeadResponse, +} from '@/tools/sixtyfour/types' +import type { ToolConfig } from '@/tools/types' + +export const sixtyfourEnrichLeadTool: ToolConfig< + SixtyfourEnrichLeadParams, + SixtyfourEnrichLeadResponse +> = { + id: 'sixtyfour_enrich_lead', + name: 'Sixtyfour Enrich Lead', + description: + 'Enrich lead information with contact details, social profiles, and company data using Sixtyfour AI.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sixtyfour API key', + }, + leadInfo: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Lead information as JSON object with key-value pairs (e.g. name, company, title, linkedin)', + }, + struct: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: + 'Fields to collect as JSON object. Keys are field names, values are descriptions (e.g. {"email": "The individual\'s email address", "phone": "Phone number"})', + }, + researchPlan: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Optional research plan to guide enrichment strategy', + }, + }, + + request: { + url: 'https://api.sixtyfour.ai/enrich-lead', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'x-api-key': params.apiKey, + }), + body: (params) => { + let leadInfo: unknown + try { + leadInfo = + typeof params.leadInfo === 'string' ? JSON.parse(params.leadInfo) : params.leadInfo + } catch { + throw new Error('leadInfo must be valid JSON') + } + let struct: unknown + try { + struct = typeof params.struct === 'string' ? JSON.parse(params.struct) : params.struct + } catch { + throw new Error('struct must be valid JSON') + } + return { + lead_info: leadInfo, + struct, + ...(params.researchPlan && { research_plan: params.researchPlan }), + } + }, + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || data.message || data.detail || `API error: ${response.status}`) + } + + return { + success: true, + output: { + notes: data.notes ?? null, + structuredData: data.structured_data ?? {}, + references: data.references ?? {}, + confidenceScore: data.confidence_score ?? null, + }, + } + }, + + outputs: { + notes: { type: 'string', description: 'Research notes about the lead', optional: true }, + structuredData: { + type: 'json', + description: 'Enriched lead data matching the requested struct fields', + }, + references: { type: 'json', description: 'Source URLs and descriptions used for enrichment' }, + confidenceScore: { + type: 'number', + description: 'Quality score for the returned data (0-10)', + optional: true, + }, + }, +} diff --git a/apps/sim/tools/sixtyfour/find_email.ts b/apps/sim/tools/sixtyfour/find_email.ts new file mode 100644 index 00000000000..9f993ae40c7 --- /dev/null +++ b/apps/sim/tools/sixtyfour/find_email.ts @@ -0,0 +1,144 @@ +import type { SixtyfourFindEmailParams, SixtyfourFindEmailResponse } from '@/tools/sixtyfour/types' +import type { ToolConfig } from '@/tools/types' + +function parseEmails(emailField: unknown): { address: string; status: string; type: string }[] { + if (!Array.isArray(emailField)) return [] + return emailField.map((entry: unknown) => { + if (Array.isArray(entry)) { + return { + address: entry[0] ?? '', + status: entry[1] ?? 'UNKNOWN', + type: entry[2] ?? 'UNKNOWN', + } + } + return { address: String(entry), status: 'UNKNOWN', type: 'UNKNOWN' } + }) +} + +export const sixtyfourFindEmailTool: ToolConfig< + SixtyfourFindEmailParams, + SixtyfourFindEmailResponse +> = { + id: 'sixtyfour_find_email', + name: 'Sixtyfour Find Email', + description: 'Find email addresses for a lead using Sixtyfour AI.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sixtyfour API key', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Full name of the person', + }, + company: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name', + }, + linkedinUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'LinkedIn profile URL', + }, + domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company website domain', + }, + phone: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Phone number', + }, + title: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Job title', + }, + mode: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email discovery mode: PROFESSIONAL (default) or PERSONAL', + }, + }, + + request: { + url: 'https://api.sixtyfour.ai/find-email', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'x-api-key': params.apiKey, + }), + body: (params) => ({ + lead: { + name: params.name, + ...(params.company && { company: params.company }), + ...(params.linkedinUrl && { linkedin: params.linkedinUrl }), + ...(params.domain && { domain: params.domain }), + ...(params.phone && { phone: params.phone }), + ...(params.title && { title: params.title }), + }, + ...(params.mode && { mode: params.mode }), + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || data.message || data.detail || `API error: ${response.status}`) + } + + return { + success: true, + output: { + name: data.name ?? null, + company: data.company ?? null, + title: data.title ?? null, + phone: data.phone ?? null, + linkedinUrl: data.linkedin ?? null, + emails: parseEmails(data.email), + personalEmails: parseEmails(data.personal_email), + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Name of the person', optional: true }, + company: { type: 'string', description: 'Company name', optional: true }, + title: { type: 'string', description: 'Job title', optional: true }, + phone: { type: 'string', description: 'Phone number', optional: true }, + linkedinUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true }, + emails: { + type: 'json', + description: 'Professional email addresses found', + properties: { + address: { type: 'string', description: 'Email address' }, + status: { type: 'string', description: 'Validation status (OK or UNKNOWN)' }, + type: { type: 'string', description: 'Email type (COMPANY or PERSONAL)' }, + }, + }, + personalEmails: { + type: 'json', + description: 'Personal email addresses found (only in PERSONAL mode)', + optional: true, + properties: { + address: { type: 'string', description: 'Email address' }, + status: { type: 'string', description: 'Validation status (OK or UNKNOWN)' }, + type: { type: 'string', description: 'Email type (COMPANY or PERSONAL)' }, + }, + }, + }, +} diff --git a/apps/sim/tools/sixtyfour/find_phone.ts b/apps/sim/tools/sixtyfour/find_phone.ts new file mode 100644 index 00000000000..14e68bffe4f --- /dev/null +++ b/apps/sim/tools/sixtyfour/find_phone.ts @@ -0,0 +1,100 @@ +import type { SixtyfourFindPhoneParams, SixtyfourFindPhoneResponse } from '@/tools/sixtyfour/types' +import type { ToolConfig } from '@/tools/types' + +export const sixtyfourFindPhoneTool: ToolConfig< + SixtyfourFindPhoneParams, + SixtyfourFindPhoneResponse +> = { + id: 'sixtyfour_find_phone', + name: 'Sixtyfour Find Phone', + description: 'Find phone numbers for a lead using Sixtyfour AI.', + version: '1.0.0', + + params: { + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Sixtyfour API key', + }, + name: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'Full name of the person', + }, + company: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company name', + }, + linkedinUrl: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'LinkedIn profile URL', + }, + domain: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Company website domain', + }, + email: { + type: 'string', + required: false, + visibility: 'user-or-llm', + description: 'Email address', + }, + }, + + request: { + url: 'https://api.sixtyfour.ai/find-phone', + method: 'POST', + headers: (params) => ({ + 'Content-Type': 'application/json', + 'x-api-key': params.apiKey, + }), + body: (params) => ({ + lead: { + name: params.name, + ...(params.company && { company: params.company }), + ...(params.linkedinUrl && { linkedin_url: params.linkedinUrl }), + ...(params.domain && { domain: params.domain }), + ...(params.email && { email: params.email }), + }, + }), + }, + + transformResponse: async (response: Response) => { + const data = await response.json() + if (!response.ok) { + throw new Error(data.error || data.message || data.detail || `API error: ${response.status}`) + } + + let phone: string | null = null + if (typeof data.phone === 'string') { + phone = data.phone || null + } else if (Array.isArray(data.phone)) { + phone = data.phone.map((p: { number: string; region?: string }) => p.number).join(', ') + } + + return { + success: true, + output: { + name: data.name ?? null, + company: data.company ?? null, + phone, + linkedinUrl: data.linkedin_url ?? null, + }, + } + }, + + outputs: { + name: { type: 'string', description: 'Name of the person', optional: true }, + company: { type: 'string', description: 'Company name', optional: true }, + phone: { type: 'string', description: 'Phone number(s) found', optional: true }, + linkedinUrl: { type: 'string', description: 'LinkedIn profile URL', optional: true }, + }, +} diff --git a/apps/sim/tools/sixtyfour/index.ts b/apps/sim/tools/sixtyfour/index.ts new file mode 100644 index 00000000000..2f12d580636 --- /dev/null +++ b/apps/sim/tools/sixtyfour/index.ts @@ -0,0 +1,4 @@ +export { sixtyfourEnrichCompanyTool } from '@/tools/sixtyfour/enrich_company' +export { sixtyfourEnrichLeadTool } from '@/tools/sixtyfour/enrich_lead' +export { sixtyfourFindEmailTool } from '@/tools/sixtyfour/find_email' +export { sixtyfourFindPhoneTool } from '@/tools/sixtyfour/find_phone' diff --git a/apps/sim/tools/sixtyfour/types.ts b/apps/sim/tools/sixtyfour/types.ts new file mode 100644 index 00000000000..e50afcab7fb --- /dev/null +++ b/apps/sim/tools/sixtyfour/types.ts @@ -0,0 +1,78 @@ +import type { ToolResponse } from '@/tools/types' + +export interface SixtyfourFindPhoneParams { + apiKey: string + name: string + company?: string + linkedinUrl?: string + domain?: string + email?: string +} + +export interface SixtyfourFindEmailParams { + apiKey: string + name: string + company?: string + linkedinUrl?: string + domain?: string + phone?: string + title?: string + mode?: string +} + +export interface SixtyfourEnrichLeadParams { + apiKey: string + leadInfo: string + struct: string + researchPlan?: string +} + +export interface SixtyfourEnrichCompanyParams { + apiKey: string + targetCompany: string + struct: string + findPeople?: boolean + fullOrgChart?: boolean + researchPlan?: string + peopleFocusPrompt?: string + leadStruct?: string +} + +export interface SixtyfourFindPhoneResponse extends ToolResponse { + output: { + name: string | null + company: string | null + phone: string | null + linkedinUrl: string | null + } +} + +export interface SixtyfourFindEmailResponse extends ToolResponse { + output: { + name: string | null + company: string | null + title: string | null + phone: string | null + linkedinUrl: string | null + emails: { address: string; status: string; type: string }[] + personalEmails: { address: string; status: string; type: string }[] + } +} + +export interface SixtyfourEnrichLeadResponse extends ToolResponse { + output: { + notes: string | null + structuredData: Record + references: Record + confidenceScore: number | null + } +} + +export interface SixtyfourEnrichCompanyResponse extends ToolResponse { + output: { + notes: string | null + structuredData: Record + references: Record + confidenceScore: number | null + } +} diff --git a/turbo.json b/turbo.json index 5d00f7846da..386c5c0de3c 100644 --- a/turbo.json +++ b/turbo.json @@ -1,5 +1,5 @@ { - "$schema": "https://turbo.build/schema.json", + "$schema": "https://v2-9-4.turborepo.dev/schema.json", "envMode": "loose", "tasks": { "transit": { From 796384a0dca891b8e513f406744110ef6ff18ff3 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 11:44:42 -0700 Subject: [PATCH 05/32] feat(triggers): add Resend webhook triggers with auto-registration (#3986) * feat(triggers): add Resend webhook triggers with auto-registration * fix(triggers): capture Resend signing secret and add Svix webhook verification * fix(triggers): add paramVisibility, event-type filtering for Resend triggers * fix(triggers): add Svix timestamp staleness check to prevent replay attacks Co-Authored-By: Claude Opus 4.6 * fix(triggers): use Number.parseInt and Number.isNaN for lint compliance Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- apps/sim/blocks/blocks/resend.ts | 24 ++ .../lib/webhooks/provider-subscriptions.ts | 2 + apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/lib/webhooks/providers/resend.ts | 294 ++++++++++++++++++ apps/sim/triggers/registry.ts | 18 ++ apps/sim/triggers/resend/email_bounced.ts | 38 +++ apps/sim/triggers/resend/email_clicked.ts | 38 +++ apps/sim/triggers/resend/email_complained.ts | 38 +++ apps/sim/triggers/resend/email_delivered.ts | 38 +++ apps/sim/triggers/resend/email_failed.ts | 38 +++ apps/sim/triggers/resend/email_opened.ts | 38 +++ apps/sim/triggers/resend/email_sent.ts | 41 +++ apps/sim/triggers/resend/index.ts | 8 + apps/sim/triggers/resend/utils.ts | 188 +++++++++++ apps/sim/triggers/resend/webhook.ts | 38 +++ 15 files changed, 843 insertions(+) create mode 100644 apps/sim/lib/webhooks/providers/resend.ts create mode 100644 apps/sim/triggers/resend/email_bounced.ts create mode 100644 apps/sim/triggers/resend/email_clicked.ts create mode 100644 apps/sim/triggers/resend/email_complained.ts create mode 100644 apps/sim/triggers/resend/email_delivered.ts create mode 100644 apps/sim/triggers/resend/email_failed.ts create mode 100644 apps/sim/triggers/resend/email_opened.ts create mode 100644 apps/sim/triggers/resend/email_sent.ts create mode 100644 apps/sim/triggers/resend/index.ts create mode 100644 apps/sim/triggers/resend/utils.ts create mode 100644 apps/sim/triggers/resend/webhook.ts diff --git a/apps/sim/blocks/blocks/resend.ts b/apps/sim/blocks/blocks/resend.ts index db3b77506a9..f4533978af0 100644 --- a/apps/sim/blocks/blocks/resend.ts +++ b/apps/sim/blocks/blocks/resend.ts @@ -1,6 +1,7 @@ import { ResendIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' +import { getTrigger } from '@/triggers' export const ResendBlock: BlockConfig = { type: 'resend', @@ -16,6 +17,20 @@ export const ResendBlock: BlockConfig = { icon: ResendIcon, authMode: AuthMode.ApiKey, + triggers: { + enabled: true, + available: [ + 'resend_email_sent', + 'resend_email_delivered', + 'resend_email_bounced', + 'resend_email_complained', + 'resend_email_opened', + 'resend_email_clicked', + 'resend_email_failed', + 'resend_webhook', + ], + }, + subBlocks: [ { id: 'operation', @@ -221,6 +236,15 @@ Return ONLY the email body - no explanations, no extra text.`, condition: { field: 'operation', value: ['get_contact', 'update_contact', 'delete_contact'] }, required: true, }, + + ...getTrigger('resend_email_sent').subBlocks, + ...getTrigger('resend_email_delivered').subBlocks, + ...getTrigger('resend_email_bounced').subBlocks, + ...getTrigger('resend_email_complained').subBlocks, + ...getTrigger('resend_email_opened').subBlocks, + ...getTrigger('resend_email_clicked').subBlocks, + ...getTrigger('resend_email_failed').subBlocks, + ...getTrigger('resend_webhook').subBlocks, ], tools: { diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts index 227e05753ab..0d9906e378a 100644 --- a/apps/sim/lib/webhooks/provider-subscriptions.ts +++ b/apps/sim/lib/webhooks/provider-subscriptions.ts @@ -23,6 +23,8 @@ const SYSTEM_MANAGED_FIELDS = new Set([ 'eventTypes', 'webhookTag', 'webhookSecret', + 'signingSecret', + 'secretToken', 'historyId', 'lastCheckedTimestamp', 'setupCompleted', diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 00ae58a21b1..60e88706a90 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -21,6 +21,7 @@ import { lemlistHandler } from '@/lib/webhooks/providers/lemlist' import { linearHandler } from '@/lib/webhooks/providers/linear' import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' import { outlookHandler } from '@/lib/webhooks/providers/outlook' +import { resendHandler } from '@/lib/webhooks/providers/resend' import { rssHandler } from '@/lib/webhooks/providers/rss' import { slackHandler } from '@/lib/webhooks/providers/slack' import { stripeHandler } from '@/lib/webhooks/providers/stripe' @@ -55,6 +56,7 @@ const PROVIDER_HANDLERS: Record = { jira: jiraHandler, lemlist: lemlistHandler, linear: linearHandler, + resend: resendHandler, 'microsoft-teams': microsoftTeamsHandler, outlook: outlookHandler, rss: rssHandler, diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts new file mode 100644 index 00000000000..82d452ba8cd --- /dev/null +++ b/apps/sim/lib/webhooks/providers/resend.ts @@ -0,0 +1,294 @@ +import crypto from 'node:crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils' +import type { + AuthContext, + DeleteSubscriptionContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + SubscriptionContext, + SubscriptionResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Resend') + +const ALL_RESEND_EVENTS = [ + 'email.sent', + 'email.delivered', + 'email.delivery_delayed', + 'email.bounced', + 'email.complained', + 'email.opened', + 'email.clicked', + 'email.failed', + 'email.received', + 'email.scheduled', + 'email.suppressed', + 'contact.created', + 'contact.updated', + 'contact.deleted', + 'domain.created', + 'domain.updated', + 'domain.deleted', +] + +/** + * Verify a Resend webhook signature using the Svix signing scheme. + * Resend uses Svix under the hood: HMAC-SHA256 of `${svix-id}.${svix-timestamp}.${body}` + * signed with the base64-decoded `whsec_...` secret. + */ +function verifySvixSignature( + secret: string, + msgId: string, + timestamp: string, + signatures: string, + rawBody: string +): boolean { + try { + const ts = Number.parseInt(timestamp, 10) + const now = Math.floor(Date.now() / 1000) + if (Number.isNaN(ts) || Math.abs(now - ts) > 5 * 60) { + return false + } + + const secretBytes = Buffer.from(secret.replace(/^whsec_/, ''), 'base64') + const toSign = `${msgId}.${timestamp}.${rawBody}` + const expectedSignature = crypto + .createHmac('sha256', secretBytes) + .update(toSign, 'utf8') + .digest('base64') + + const providedSignatures = signatures.split(' ') + for (const versionedSig of providedSignatures) { + const parts = versionedSig.split(',') + if (parts.length !== 2) continue + const sig = parts[1] + if (safeCompare(sig, expectedSignature)) { + return true + } + } + return false + } catch (error) { + logger.error('Error verifying Resend Svix signature:', error) + return false + } +} + +export const resendHandler: WebhookProviderHandler = { + async verifyAuth({ + request, + rawBody, + requestId, + providerConfig, + }: AuthContext): Promise { + const signingSecret = providerConfig.signingSecret as string | undefined + if (!signingSecret) { + return null + } + + const svixId = request.headers.get('svix-id') + const svixTimestamp = request.headers.get('svix-timestamp') + const svixSignature = request.headers.get('svix-signature') + + if (!svixId || !svixTimestamp || !svixSignature) { + logger.warn(`[${requestId}] Resend webhook missing Svix signature headers`) + return new NextResponse('Unauthorized - Missing Resend signature headers', { status: 401 }) + } + + if (!verifySvixSignature(signingSecret, svixId, svixTimestamp, svixSignature, rawBody)) { + logger.warn(`[${requestId}] Resend Svix signature verification failed`) + return new NextResponse('Unauthorized - Invalid Resend signature', { status: 401 }) + } + + return null + }, + + matchEvent({ body, providerConfig, requestId }: EventMatchContext): boolean { + const triggerId = providerConfig.triggerId as string | undefined + if (!triggerId || triggerId === 'resend_webhook') { + return true + } + + const EVENT_TYPE_MAP: Record = { + resend_email_sent: 'email.sent', + resend_email_delivered: 'email.delivered', + resend_email_bounced: 'email.bounced', + resend_email_complained: 'email.complained', + resend_email_opened: 'email.opened', + resend_email_clicked: 'email.clicked', + resend_email_failed: 'email.failed', + } + + const expectedType = EVENT_TYPE_MAP[triggerId] + const actualType = (body as Record)?.type as string | undefined + + if (expectedType && actualType !== expectedType) { + logger.debug( + `[${requestId}] Resend event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.` + ) + return false + } + + return true + }, + + async formatInput({ body }: FormatInputContext): Promise { + const payload = body as Record + const data = payload.data as Record | undefined + const bounce = data?.bounce as Record | undefined + const click = data?.click as Record | undefined + + return { + input: { + type: payload.type, + created_at: payload.created_at, + email_id: data?.email_id ?? null, + from: data?.from ?? null, + to: data?.to ?? null, + subject: data?.subject ?? null, + bounceType: bounce?.type ?? null, + bounceSubType: bounce?.subType ?? null, + bounceMessage: bounce?.message ?? null, + clickIpAddress: click?.ipAddress ?? null, + clickLink: click?.link ?? null, + clickTimestamp: click?.timestamp ?? null, + clickUserAgent: click?.userAgent ?? null, + }, + } + }, + + async createSubscription(ctx: SubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const providerConfig = getProviderConfig(webhook) + const apiKey = providerConfig.apiKey as string | undefined + const triggerId = providerConfig.triggerId as string | undefined + + if (!apiKey) { + logger.warn(`[${requestId}] Missing apiKey for Resend webhook creation.`, { + webhookId: webhook.id, + }) + throw new Error( + 'Resend API Key is required. Please provide your Resend API Key in the trigger configuration.' + ) + } + + const eventTypeMap: Record = { + resend_email_sent: ['email.sent'], + resend_email_delivered: ['email.delivered'], + resend_email_bounced: ['email.bounced'], + resend_email_complained: ['email.complained'], + resend_email_opened: ['email.opened'], + resend_email_clicked: ['email.clicked'], + resend_email_failed: ['email.failed'], + resend_webhook: ALL_RESEND_EVENTS, + } + + const events = eventTypeMap[triggerId ?? ''] ?? ALL_RESEND_EVENTS + const notificationUrl = getNotificationUrl(webhook) + + logger.info(`[${requestId}] Creating Resend webhook`, { + triggerId, + events, + webhookId: webhook.id, + }) + + const resendResponse = await fetch('https://api.resend.com/webhooks', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + endpoint: notificationUrl, + events, + }), + }) + + const responseBody = (await resendResponse.json()) as Record + + if (!resendResponse.ok) { + const errorMessage = + (responseBody.message as string) || + (responseBody.name as string) || + 'Unknown Resend API error' + logger.error( + `[${requestId}] Failed to create webhook in Resend for webhook ${webhook.id}. Status: ${resendResponse.status}`, + { message: errorMessage, response: responseBody } + ) + + let userFriendlyMessage = 'Failed to create webhook subscription in Resend' + if (resendResponse.status === 401 || resendResponse.status === 403) { + userFriendlyMessage = 'Invalid Resend API Key. Please verify your API Key is correct.' + } else if (errorMessage && errorMessage !== 'Unknown Resend API error') { + userFriendlyMessage = `Resend error: ${errorMessage}` + } + + throw new Error(userFriendlyMessage) + } + + logger.info( + `[${requestId}] Successfully created webhook in Resend for webhook ${webhook.id}.`, + { + resendWebhookId: responseBody.id, + } + ) + + return { + providerConfigUpdates: { + externalId: responseBody.id, + signingSecret: responseBody.signing_secret, + }, + } + } catch (error: unknown) { + const err = error as Error + logger.error( + `[${requestId}] Exception during Resend webhook creation for webhook ${webhook.id}.`, + { + message: err.message, + stack: err.stack, + } + ) + throw error + } + }, + + async deleteSubscription(ctx: DeleteSubscriptionContext): Promise { + const { webhook, requestId } = ctx + try { + const config = getProviderConfig(webhook) + const apiKey = config.apiKey as string | undefined + const externalId = config.externalId as string | undefined + + if (!apiKey || !externalId) { + logger.warn( + `[${requestId}] Missing apiKey or externalId for Resend webhook deletion ${webhook.id}, skipping cleanup` + ) + return + } + + const resendResponse = await fetch(`https://api.resend.com/webhooks/${externalId}`, { + method: 'DELETE', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + + if (!resendResponse.ok && resendResponse.status !== 404) { + const responseBody = await resendResponse.json().catch(() => ({})) + logger.warn( + `[${requestId}] Failed to delete Resend webhook (non-fatal): ${resendResponse.status}`, + { response: responseBody } + ) + } else { + logger.info(`[${requestId}] Successfully deleted Resend webhook ${externalId}`) + } + } catch (error) { + logger.warn(`[${requestId}] Error deleting Resend webhook (non-fatal)`, error) + } + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 3c1845a7fd0..f44ac336194 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -171,6 +171,16 @@ import { microsoftTeamsWebhookTrigger, } from '@/triggers/microsoftteams' import { outlookPollingTrigger } from '@/triggers/outlook' +import { + resendEmailBouncedTrigger, + resendEmailClickedTrigger, + resendEmailComplainedTrigger, + resendEmailDeliveredTrigger, + resendEmailFailedTrigger, + resendEmailOpenedTrigger, + resendEmailSentTrigger, + resendWebhookTrigger, +} from '@/triggers/resend' import { rssPollingTrigger } from '@/triggers/rss' import { salesforceCaseStatusChangedTrigger, @@ -315,6 +325,14 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { microsoftteams_webhook: microsoftTeamsWebhookTrigger, microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger, outlook_poller: outlookPollingTrigger, + resend_email_sent: resendEmailSentTrigger, + resend_email_delivered: resendEmailDeliveredTrigger, + resend_email_bounced: resendEmailBouncedTrigger, + resend_email_complained: resendEmailComplainedTrigger, + resend_email_opened: resendEmailOpenedTrigger, + resend_email_clicked: resendEmailClickedTrigger, + resend_email_failed: resendEmailFailedTrigger, + resend_webhook: resendWebhookTrigger, rss_poller: rssPollingTrigger, salesforce_record_created: salesforceRecordCreatedTrigger, salesforce_record_updated: salesforceRecordUpdatedTrigger, diff --git a/apps/sim/triggers/resend/email_bounced.ts b/apps/sim/triggers/resend/email_bounced.ts new file mode 100644 index 00000000000..8d2c4779b5a --- /dev/null +++ b/apps/sim/triggers/resend/email_bounced.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailBouncedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Bounced Trigger + * Triggers when an email permanently bounces. + */ +export const resendEmailBouncedTrigger: TriggerConfig = { + id: 'resend_email_bounced', + name: 'Resend Email Bounced', + provider: 'resend', + description: 'Trigger workflow when an email bounces', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_bounced', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.bounced'), + extraFields: buildResendExtraFields('resend_email_bounced'), + }), + + outputs: buildEmailBouncedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_clicked.ts b/apps/sim/triggers/resend/email_clicked.ts new file mode 100644 index 00000000000..437f3c9a30b --- /dev/null +++ b/apps/sim/triggers/resend/email_clicked.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailClickedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Clicked Trigger + * Triggers when a recipient clicks a link in an email. + */ +export const resendEmailClickedTrigger: TriggerConfig = { + id: 'resend_email_clicked', + name: 'Resend Email Clicked', + provider: 'resend', + description: 'Trigger workflow when a link in an email is clicked', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_clicked', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.clicked'), + extraFields: buildResendExtraFields('resend_email_clicked'), + }), + + outputs: buildEmailClickedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_complained.ts b/apps/sim/triggers/resend/email_complained.ts new file mode 100644 index 00000000000..211ab85bd8c --- /dev/null +++ b/apps/sim/triggers/resend/email_complained.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailComplainedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Complained Trigger + * Triggers when a recipient marks an email as spam. + */ +export const resendEmailComplainedTrigger: TriggerConfig = { + id: 'resend_email_complained', + name: 'Resend Email Complained', + provider: 'resend', + description: 'Trigger workflow when an email is marked as spam', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_complained', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.complained'), + extraFields: buildResendExtraFields('resend_email_complained'), + }), + + outputs: buildEmailComplainedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_delivered.ts b/apps/sim/triggers/resend/email_delivered.ts new file mode 100644 index 00000000000..ac7a0d9e914 --- /dev/null +++ b/apps/sim/triggers/resend/email_delivered.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailDeliveredOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Delivered Trigger + * Triggers when an email is successfully delivered to the recipient's mail server. + */ +export const resendEmailDeliveredTrigger: TriggerConfig = { + id: 'resend_email_delivered', + name: 'Resend Email Delivered', + provider: 'resend', + description: 'Trigger workflow when an email is delivered', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_delivered', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.delivered'), + extraFields: buildResendExtraFields('resend_email_delivered'), + }), + + outputs: buildEmailDeliveredOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_failed.ts b/apps/sim/triggers/resend/email_failed.ts new file mode 100644 index 00000000000..f1a753aece0 --- /dev/null +++ b/apps/sim/triggers/resend/email_failed.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailFailedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Failed Trigger + * Triggers when an email fails to send. + */ +export const resendEmailFailedTrigger: TriggerConfig = { + id: 'resend_email_failed', + name: 'Resend Email Failed', + provider: 'resend', + description: 'Trigger workflow when an email fails to send', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_failed', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.failed'), + extraFields: buildResendExtraFields('resend_email_failed'), + }), + + outputs: buildEmailFailedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_opened.ts b/apps/sim/triggers/resend/email_opened.ts new file mode 100644 index 00000000000..0aaee9bb7cc --- /dev/null +++ b/apps/sim/triggers/resend/email_opened.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailOpenedOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Opened Trigger + * Triggers when a recipient opens an email. + */ +export const resendEmailOpenedTrigger: TriggerConfig = { + id: 'resend_email_opened', + name: 'Resend Email Opened', + provider: 'resend', + description: 'Trigger workflow when an email is opened', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_opened', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('email.opened'), + extraFields: buildResendExtraFields('resend_email_opened'), + }), + + outputs: buildEmailOpenedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/email_sent.ts b/apps/sim/triggers/resend/email_sent.ts new file mode 100644 index 00000000000..d4abd6e7adb --- /dev/null +++ b/apps/sim/triggers/resend/email_sent.ts @@ -0,0 +1,41 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildEmailSentOutputs, + buildResendExtraFields, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Resend Email Sent Trigger + * Triggers when an email is sent by Resend. + * + * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. + */ +export const resendEmailSentTrigger: TriggerConfig = { + id: 'resend_email_sent', + name: 'Resend Email Sent', + provider: 'resend', + description: 'Trigger workflow when an email is sent', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_email_sent', + triggerOptions: resendTriggerOptions, + includeDropdown: true, + setupInstructions: resendSetupInstructions('email.sent'), + extraFields: buildResendExtraFields('resend_email_sent'), + }), + + outputs: buildEmailSentOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/resend/index.ts b/apps/sim/triggers/resend/index.ts new file mode 100644 index 00000000000..86cac19449b --- /dev/null +++ b/apps/sim/triggers/resend/index.ts @@ -0,0 +1,8 @@ +export { resendEmailBouncedTrigger } from './email_bounced' +export { resendEmailClickedTrigger } from './email_clicked' +export { resendEmailComplainedTrigger } from './email_complained' +export { resendEmailDeliveredTrigger } from './email_delivered' +export { resendEmailFailedTrigger } from './email_failed' +export { resendEmailOpenedTrigger } from './email_opened' +export { resendEmailSentTrigger } from './email_sent' +export { resendWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/resend/utils.ts b/apps/sim/triggers/resend/utils.ts new file mode 100644 index 00000000000..3ab99c35692 --- /dev/null +++ b/apps/sim/triggers/resend/utils.ts @@ -0,0 +1,188 @@ +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all Resend triggers + */ +export const resendTriggerOptions = [ + { label: 'Email Sent', id: 'resend_email_sent' }, + { label: 'Email Delivered', id: 'resend_email_delivered' }, + { label: 'Email Bounced', id: 'resend_email_bounced' }, + { label: 'Email Complained', id: 'resend_email_complained' }, + { label: 'Email Opened', id: 'resend_email_opened' }, + { label: 'Email Clicked', id: 'resend_email_clicked' }, + { label: 'Email Failed', id: 'resend_email_failed' }, + { label: 'Generic Webhook (All Events)', id: 'resend_webhook' }, +] + +/** + * Generates setup instructions for Resend webhooks. + * The webhook is automatically created in Resend when you save. + */ +export function resendSetupInstructions(eventType: string): string { + const instructions = [ + 'Enter your Resend API Key above.', + 'You can find your API key in Resend at Settings > API Keys. See the Resend API documentation for details.', + `Click "Save Configuration" to automatically create the webhook in Resend for ${eventType} events.`, + 'The webhook will be automatically deleted when you remove this trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Helper to build Resend-specific extra fields. + * Includes API key (required). + * Use with the generic buildTriggerSubBlocks from @/triggers. + */ +export function buildResendExtraFields(triggerId: string) { + return [ + { + id: 'apiKey', + title: 'API Key', + type: 'short-input' as const, + placeholder: 'Enter your Resend API key (re_...)', + description: 'Required to create the webhook in Resend.', + password: true, + paramVisibility: 'user-only' as const, + required: true, + mode: 'trigger' as const, + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Common fields present in all Resend email webhook payloads + */ +const commonEmailOutputs = { + type: { + type: 'string', + description: 'Event type (e.g., email.sent, email.delivered)', + }, + created_at: { + type: 'string', + description: 'Event creation timestamp (ISO 8601)', + }, + email_id: { + type: 'string', + description: 'Unique email identifier', + }, + from: { + type: 'string', + description: 'Sender email address', + }, + subject: { + type: 'string', + description: 'Email subject line', + }, +} as const + +/** + * Recipient fields present in email webhook payloads + */ +const recipientOutputs = { + to: { + type: 'json', + description: 'Array of recipient email addresses', + }, +} as const + +/** + * Build outputs for email sent events + */ +export function buildEmailSentOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for email delivered events + */ +export function buildEmailDeliveredOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for email bounced events + */ +export function buildEmailBouncedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + bounceType: { type: 'string', description: 'Bounce type (e.g., Permanent)' }, + bounceSubType: { type: 'string', description: 'Bounce sub-type (e.g., Suppressed)' }, + bounceMessage: { type: 'string', description: 'Bounce error message' }, + } as Record +} + +/** + * Build outputs for email complained events + */ +export function buildEmailComplainedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for email opened events + */ +export function buildEmailOpenedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for email clicked events + */ +export function buildEmailClickedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + clickIpAddress: { type: 'string', description: 'IP address of the click' }, + clickLink: { type: 'string', description: 'URL that was clicked' }, + clickTimestamp: { type: 'string', description: 'Click timestamp (ISO 8601)' }, + clickUserAgent: { type: 'string', description: 'Browser user agent string' }, + } as Record +} + +/** + * Build outputs for email failed events + */ +export function buildEmailFailedOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + } as Record +} + +/** + * Build outputs for generic webhook (all events). + * Includes all possible fields across event types. + */ +export function buildResendOutputs(): Record { + return { + ...commonEmailOutputs, + ...recipientOutputs, + bounceType: { type: 'string', description: 'Bounce type (e.g., Permanent)' }, + bounceSubType: { type: 'string', description: 'Bounce sub-type (e.g., Suppressed)' }, + bounceMessage: { type: 'string', description: 'Bounce error message' }, + clickIpAddress: { type: 'string', description: 'IP address of the click' }, + clickLink: { type: 'string', description: 'URL that was clicked' }, + clickTimestamp: { type: 'string', description: 'Click timestamp (ISO 8601)' }, + clickUserAgent: { type: 'string', description: 'Browser user agent string' }, + } as Record +} diff --git a/apps/sim/triggers/resend/webhook.ts b/apps/sim/triggers/resend/webhook.ts new file mode 100644 index 00000000000..e320f0be7aa --- /dev/null +++ b/apps/sim/triggers/resend/webhook.ts @@ -0,0 +1,38 @@ +import { ResendIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildResendExtraFields, + buildResendOutputs, + resendSetupInstructions, + resendTriggerOptions, +} from '@/triggers/resend/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Generic Resend Webhook Trigger + * Captures all Resend webhook events + */ +export const resendWebhookTrigger: TriggerConfig = { + id: 'resend_webhook', + name: 'Resend Webhook (All Events)', + provider: 'resend', + description: 'Trigger workflow on any Resend webhook event', + version: '1.0.0', + icon: ResendIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'resend_webhook', + triggerOptions: resendTriggerOptions, + setupInstructions: resendSetupInstructions('All Events'), + extraFields: buildResendExtraFields('resend_webhook'), + }), + + outputs: buildResendOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} From 62ea0f1d41824e513dd97db2f78c304bc00e83fa Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 11:45:17 -0700 Subject: [PATCH 06/32] feat(triggers): add Gong webhook triggers for call events (#3984) * feat(triggers): add Gong webhook triggers for call events * fix(triggers): reorder Gong trigger spread and dropdown options * fix(triggers): resolve Biome lint errors in Gong trigger files * json --- .../integrations/data/integrations.json | 15 +++- apps/sim/blocks/blocks/gong.ts | 8 ++ apps/sim/lib/webhooks/providers/gong.ts | 25 ++++++ apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/triggers/gong/call_completed.ts | 68 +++++++++++++++ apps/sim/triggers/gong/index.ts | 2 + apps/sim/triggers/gong/utils.ts | 84 +++++++++++++++++++ apps/sim/triggers/gong/webhook.ts | 77 +++++++++++++++++ apps/sim/triggers/registry.ts | 3 + 9 files changed, 282 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/gong.ts create mode 100644 apps/sim/triggers/gong/call_completed.ts create mode 100644 apps/sim/triggers/gong/index.ts create mode 100644 apps/sim/triggers/gong/utils.ts create mode 100644 apps/sim/triggers/gong/webhook.ts diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 55ab9caad0d..f78e275ada9 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -4106,8 +4106,19 @@ } ], "operationCount": 18, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "gong_webhook", + "name": "Gong Webhook", + "description": "Generic webhook trigger for all Gong events" + }, + { + "id": "gong_call_completed", + "name": "Gong Call Completed", + "description": "Trigger workflow when a call is completed and processed in Gong" + } + ], + "triggerCount": 2, "authType": "none", "category": "tools", "integrationType": "sales-intelligence", diff --git a/apps/sim/blocks/blocks/gong.ts b/apps/sim/blocks/blocks/gong.ts index 33adaf28742..ef41be5d22f 100644 --- a/apps/sim/blocks/blocks/gong.ts +++ b/apps/sim/blocks/blocks/gong.ts @@ -1,6 +1,7 @@ import { GongIcon } from '@/components/icons' import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types' import type { GongResponse } from '@/tools/gong/types' +import { getTrigger } from '@/triggers' export const GongBlock: BlockConfig = { type: 'gong', @@ -15,7 +16,10 @@ export const GongBlock: BlockConfig = { tags: ['meeting', 'sales-engagement', 'speech-to-text'], bgColor: '#8039DF', icon: GongIcon, + triggerAllowed: true, subBlocks: [ + ...getTrigger('gong_webhook').subBlocks, + ...getTrigger('gong_call_completed').subBlocks, { id: 'operation', title: 'Operation', @@ -568,4 +572,8 @@ Return ONLY the timestamp string in ISO 8601 format - no explanations, no quotes description: 'Gong API response data', }, }, + triggers: { + enabled: true, + available: ['gong_webhook', 'gong_call_completed'], + }, } diff --git a/apps/sim/lib/webhooks/providers/gong.ts b/apps/sim/lib/webhooks/providers/gong.ts new file mode 100644 index 00000000000..c3272f5c4c1 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/gong.ts @@ -0,0 +1,25 @@ +import type { + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +export const gongHandler: WebhookProviderHandler = { + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + const callData = b.callData as Record | undefined + const metaData = (callData?.metaData as Record) || {} + const content = callData?.content as Record | undefined + + return { + input: { + isTest: b.isTest ?? false, + callData, + metaData, + parties: (callData?.parties as unknown[]) || [], + context: (callData?.context as unknown[]) || [], + trackers: (content?.trackers as unknown[]) || [], + }, + } + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 60e88706a90..1424ec6a830 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -12,6 +12,7 @@ import { firefliesHandler } from '@/lib/webhooks/providers/fireflies' import { genericHandler } from '@/lib/webhooks/providers/generic' import { githubHandler } from '@/lib/webhooks/providers/github' import { gmailHandler } from '@/lib/webhooks/providers/gmail' +import { gongHandler } from '@/lib/webhooks/providers/gong' import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' import { grainHandler } from '@/lib/webhooks/providers/grain' import { hubspotHandler } from '@/lib/webhooks/providers/hubspot' @@ -48,6 +49,7 @@ const PROVIDER_HANDLERS: Record = { generic: genericHandler, gmail: gmailHandler, github: githubHandler, + gong: gongHandler, google_forms: googleFormsHandler, fathom: fathomHandler, grain: grainHandler, diff --git a/apps/sim/triggers/gong/call_completed.ts b/apps/sim/triggers/gong/call_completed.ts new file mode 100644 index 00000000000..b98e6bd5c22 --- /dev/null +++ b/apps/sim/triggers/gong/call_completed.ts @@ -0,0 +1,68 @@ +import { GongIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildCallOutputs, gongSetupInstructions } from './utils' + +/** + * Gong Call Completed Trigger + * + * Secondary trigger - does NOT include the dropdown (the generic webhook trigger has it). + * Fires when a call matching the configured rule is processed in Gong. + */ +export const gongCallCompletedTrigger: TriggerConfig = { + id: 'gong_call_completed', + name: 'Gong Call Completed', + provider: 'gong', + description: 'Trigger workflow when a call is completed and processed in Gong', + version: '1.0.0', + icon: GongIcon, + + subBlocks: [ + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'gong_call_completed', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'gong_call_completed', + condition: { + field: 'selectedTriggerId', + value: 'gong_call_completed', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: gongSetupInstructions('Call Completed'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'gong_call_completed', + }, + }, + ], + + outputs: buildCallOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/gong/index.ts b/apps/sim/triggers/gong/index.ts new file mode 100644 index 00000000000..e27d2ad6c2a --- /dev/null +++ b/apps/sim/triggers/gong/index.ts @@ -0,0 +1,2 @@ +export { gongCallCompletedTrigger } from './call_completed' +export { gongWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/gong/utils.ts b/apps/sim/triggers/gong/utils.ts new file mode 100644 index 00000000000..a203478ba27 --- /dev/null +++ b/apps/sim/triggers/gong/utils.ts @@ -0,0 +1,84 @@ +import type { TriggerOutput } from '@/triggers/types' + +/** + * Shared trigger dropdown options for all Gong triggers + */ +export const gongTriggerOptions = [ + { label: 'General Webhook (All Events)', id: 'gong_webhook' }, + { label: 'Call Completed', id: 'gong_call_completed' }, +] + +/** + * Generate setup instructions for a specific Gong event type + */ +export function gongSetupInstructions(eventType: string): string { + const instructions = [ + 'Note: You need admin access to Gong to set up webhooks. See the Gong webhook documentation for details.', + 'Copy the Webhook URL above.', + 'In Gong, go to Admin center > Settings > Ecosystem > Automation rules.', + 'Click "+ Add Rule" to create a new automation rule.', + `Configure rule filters to match ${eventType} calls.`, + 'Under Actions, select "Fire webhook".', + 'Paste the Webhook URL into the destination field.', + 'Choose an authentication method (URL includes key or Signed JWT header).', + 'Save the rule and click "Save" above to activate your trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index === 0 ? instruction : `${index}. ${instruction}`}
` + ) + .join('') +} + +/** + * Build output schema for call events. + * Gong webhooks deliver call data including metadata, participants, context, and content analysis. + */ +export function buildCallOutputs(): Record { + return { + isTest: { + type: 'boolean', + description: 'Whether this is a test webhook from the Gong UI', + }, + callData: { + type: 'json', + description: 'Full call data object', + }, + metaData: { + id: { type: 'string', description: 'Gong call ID' }, + url: { type: 'string', description: 'URL to the call in Gong' }, + title: { type: 'string', description: 'Call title' }, + scheduled: { type: 'string', description: 'Scheduled start time (ISO 8601)' }, + started: { type: 'string', description: 'Actual start time (ISO 8601)' }, + duration: { type: 'number', description: 'Call duration in seconds' }, + primaryUserId: { type: 'string', description: 'Primary Gong user ID' }, + direction: { type: 'string', description: 'Call direction (Conference, Call, etc.)' }, + system: { type: 'string', description: 'Meeting system (Zoom, Teams, etc.)' }, + scope: { type: 'string', description: 'Call scope (External or Internal)' }, + media: { type: 'string', description: 'Media type (Video or Audio)' }, + language: { type: 'string', description: 'Call language code' }, + }, + parties: { + type: 'array', + description: 'Array of call participants with name, email, title, and affiliation', + }, + context: { + type: 'array', + description: 'Array of CRM context objects (Salesforce opportunities, accounts, etc.)', + }, + trackers: { + type: 'array', + description: 'Array of tracked topics/keywords with counts', + }, + } as Record +} + +/** + * Build output schema for generic webhook events. + * Uses the same call output structure since Gong webhooks primarily deliver call data. + */ +export function buildGenericOutputs(): Record { + return buildCallOutputs() +} diff --git a/apps/sim/triggers/gong/webhook.ts b/apps/sim/triggers/gong/webhook.ts new file mode 100644 index 00000000000..164a81f2d23 --- /dev/null +++ b/apps/sim/triggers/gong/webhook.ts @@ -0,0 +1,77 @@ +import { GongIcon } from '@/components/icons' +import type { TriggerConfig } from '@/triggers/types' +import { buildGenericOutputs, gongSetupInstructions, gongTriggerOptions } from './utils' + +/** + * Gong Generic Webhook Trigger + * + * Primary trigger - includes the dropdown for selecting trigger type. + * Accepts all webhook events from Gong automation rules. + */ +export const gongWebhookTrigger: TriggerConfig = { + id: 'gong_webhook', + name: 'Gong Webhook', + provider: 'gong', + description: 'Generic webhook trigger for all Gong events', + version: '1.0.0', + icon: GongIcon, + + subBlocks: [ + { + id: 'selectedTriggerId', + title: 'Trigger Type', + type: 'dropdown', + mode: 'trigger', + options: gongTriggerOptions, + value: () => 'gong_webhook', + required: true, + }, + { + id: 'webhookUrlDisplay', + title: 'Webhook URL', + type: 'short-input', + readOnly: true, + showCopyButton: true, + useWebhookUrl: true, + placeholder: 'Webhook URL will be generated', + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'gong_webhook', + }, + }, + { + id: 'triggerSave', + title: '', + type: 'trigger-save', + hideFromPreview: true, + mode: 'trigger', + triggerId: 'gong_webhook', + condition: { + field: 'selectedTriggerId', + value: 'gong_webhook', + }, + }, + { + id: 'triggerInstructions', + title: 'Setup Instructions', + hideFromPreview: true, + type: 'text', + defaultValue: gongSetupInstructions('All Events'), + mode: 'trigger', + condition: { + field: 'selectedTriggerId', + value: 'gong_webhook', + }, + }, + ], + + outputs: buildGenericOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index f44ac336194..131a0117770 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -89,6 +89,7 @@ import { githubWorkflowRunTrigger, } from '@/triggers/github' import { gmailPollingTrigger } from '@/triggers/gmail' +import { gongCallCompletedTrigger, gongWebhookTrigger } from '@/triggers/gong' import { googleFormsWebhookTrigger } from '@/triggers/googleforms' import { grainHighlightCreatedTrigger, @@ -281,6 +282,8 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { fathom_new_meeting: fathomNewMeetingTrigger, fathom_webhook: fathomWebhookTrigger, gmail_poller: gmailPollingTrigger, + gong_call_completed: gongCallCompletedTrigger, + gong_webhook: gongWebhookTrigger, grain_webhook: grainWebhookTrigger, grain_item_added: grainItemAddedTrigger, grain_item_updated: grainItemUpdatedTrigger, From 590f37641cbe24297f6368e9f759241bffe3eb74 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 11:49:28 -0700 Subject: [PATCH 07/32] feat(triggers): add Intercom webhook triggers (#3990) * feat(triggers): add Intercom webhook triggers * fix(triggers): address PR review feedback for Intercom triggers --- .../integrations/data/integrations.json | 35 ++++- apps/sim/blocks/blocks/intercom.ts | 21 +++ apps/sim/lib/webhooks/providers/intercom.ts | 120 ++++++++++++++++ apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/triggers/intercom/contact_created.ts | 41 ++++++ .../triggers/intercom/conversation_closed.ts | 39 ++++++ .../triggers/intercom/conversation_created.ts | 43 ++++++ .../triggers/intercom/conversation_reply.ts | 41 ++++++ apps/sim/triggers/intercom/index.ts | 6 + apps/sim/triggers/intercom/user_created.ts | 41 ++++++ apps/sim/triggers/intercom/utils.ts | 128 ++++++++++++++++++ apps/sim/triggers/intercom/webhook.ts | 41 ++++++ apps/sim/triggers/registry.ts | 14 ++ 13 files changed, 570 insertions(+), 2 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/intercom.ts create mode 100644 apps/sim/triggers/intercom/contact_created.ts create mode 100644 apps/sim/triggers/intercom/conversation_closed.ts create mode 100644 apps/sim/triggers/intercom/conversation_created.ts create mode 100644 apps/sim/triggers/intercom/conversation_reply.ts create mode 100644 apps/sim/triggers/intercom/index.ts create mode 100644 apps/sim/triggers/intercom/user_created.ts create mode 100644 apps/sim/triggers/intercom/utils.ts create mode 100644 apps/sim/triggers/intercom/webhook.ts diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index f78e275ada9..189e0a3b243 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -6088,8 +6088,39 @@ } ], "operationCount": 31, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "intercom_conversation_created", + "name": "Intercom Conversation Created", + "description": "Trigger workflow when a new conversation is created in Intercom" + }, + { + "id": "intercom_conversation_reply", + "name": "Intercom Conversation Reply", + "description": "Trigger workflow when someone replies to an Intercom conversation" + }, + { + "id": "intercom_conversation_closed", + "name": "Intercom Conversation Closed", + "description": "Trigger workflow when a conversation is closed in Intercom" + }, + { + "id": "intercom_contact_created", + "name": "Intercom Contact Created", + "description": "Trigger workflow when a new lead is created in Intercom" + }, + { + "id": "intercom_user_created", + "name": "Intercom User Created", + "description": "Trigger workflow when a new user is created in Intercom" + }, + { + "id": "intercom_webhook", + "name": "Intercom Webhook (All Events)", + "description": "Trigger workflow on any Intercom webhook event" + } + ], + "triggerCount": 6, "authType": "api-key", "category": "tools", "integrationType": "customer-support", diff --git a/apps/sim/blocks/blocks/intercom.ts b/apps/sim/blocks/blocks/intercom.ts index 21b8124324a..a044cb57ca0 100644 --- a/apps/sim/blocks/blocks/intercom.ts +++ b/apps/sim/blocks/blocks/intercom.ts @@ -2,6 +2,7 @@ import { IntercomIcon } from '@/components/icons' import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { createVersionedToolSelector } from '@/blocks/utils' +import { getTrigger } from '@/triggers' export const IntercomBlock: BlockConfig = { type: 'intercom', @@ -1409,6 +1410,26 @@ export const IntercomV2Block: BlockConfig = { integrationType: IntegrationType.CustomerSupport, tags: ['customer-support', 'messaging'], hideFromToolbar: false, + subBlocks: [ + ...IntercomBlock.subBlocks, + ...getTrigger('intercom_conversation_created').subBlocks, + ...getTrigger('intercom_conversation_reply').subBlocks, + ...getTrigger('intercom_conversation_closed').subBlocks, + ...getTrigger('intercom_contact_created').subBlocks, + ...getTrigger('intercom_user_created').subBlocks, + ...getTrigger('intercom_webhook').subBlocks, + ], + triggers: { + enabled: true, + available: [ + 'intercom_conversation_created', + 'intercom_conversation_reply', + 'intercom_conversation_closed', + 'intercom_contact_created', + 'intercom_user_created', + 'intercom_webhook', + ], + }, tools: { ...IntercomBlock.tools, access: [ diff --git a/apps/sim/lib/webhooks/providers/intercom.ts b/apps/sim/lib/webhooks/providers/intercom.ts new file mode 100644 index 00000000000..5957a93b1fe --- /dev/null +++ b/apps/sim/lib/webhooks/providers/intercom.ts @@ -0,0 +1,120 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + AuthContext, + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' + +const logger = createLogger('WebhookProvider:Intercom') + +/** + * Validate Intercom webhook signature using HMAC-SHA1. + * Intercom signs payloads with the app's Client Secret and sends the + * signature in the X-Hub-Signature header as "sha1=". + */ +function validateIntercomSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Intercom signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + + if (!signature.startsWith('sha1=')) { + logger.warn('Intercom signature has invalid format', { + signature: `${signature.substring(0, 10)}...`, + }) + return false + } + + const providedSignature = signature.substring(5) + const computedHash = crypto.createHmac('sha1', secret).update(body, 'utf8').digest('hex') + + return safeCompare(computedHash, providedSignature) + } catch (error) { + logger.error('Error validating Intercom signature:', error) + return false + } +} + +export const intercomHandler: WebhookProviderHandler = { + verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) { + const secret = providerConfig.webhookSecret as string | undefined + if (!secret) { + return null + } + + const signature = request.headers.get('X-Hub-Signature') + if (!signature) { + logger.warn(`[${requestId}] Intercom webhook missing X-Hub-Signature header`) + return new NextResponse('Unauthorized - Missing Intercom signature', { status: 401 }) + } + + if (!validateIntercomSignature(secret, signature, rawBody)) { + logger.warn(`[${requestId}] Intercom signature verification failed`, { + signatureLength: signature.length, + secretLength: secret.length, + }) + return new NextResponse('Unauthorized - Invalid Intercom signature', { status: 401 }) + } + + return null + }, + + handleReachabilityTest(body: unknown, requestId: string) { + const obj = body as Record | null + if (obj?.topic === 'ping') { + logger.info( + `[${requestId}] Intercom ping event detected - returning 200 without triggering workflow` + ) + return NextResponse.json({ + status: 'ok', + message: 'Webhook endpoint verified', + }) + } + return null + }, + + async formatInput({ body }: FormatInputContext): Promise { + return { input: body } + }, + + async matchEvent({ webhook, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + const topic = obj?.topic as string | undefined + + if (triggerId && triggerId !== 'intercom_webhook') { + const { isIntercomEventMatch } = await import('@/triggers/intercom/utils') + if (!isIntercomEventMatch(triggerId, topic || '')) { + logger.debug( + `[${requestId}] Intercom event mismatch for trigger ${triggerId}. Topic: ${topic}. Skipping execution.`, + { + webhookId: webhook.id, + triggerId, + receivedTopic: topic, + } + ) + return false + } + } + + return true + }, + + extractIdempotencyId(body: unknown) { + const obj = body as Record + if (obj?.id && obj?.type === 'notification_event') { + return String(obj.id) + } + return null + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 1424ec6a830..1bea27ebc15 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -17,6 +17,7 @@ import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' import { grainHandler } from '@/lib/webhooks/providers/grain' import { hubspotHandler } from '@/lib/webhooks/providers/hubspot' import { imapHandler } from '@/lib/webhooks/providers/imap' +import { intercomHandler } from '@/lib/webhooks/providers/intercom' import { jiraHandler } from '@/lib/webhooks/providers/jira' import { lemlistHandler } from '@/lib/webhooks/providers/lemlist' import { linearHandler } from '@/lib/webhooks/providers/linear' @@ -55,6 +56,7 @@ const PROVIDER_HANDLERS: Record = { grain: grainHandler, hubspot: hubspotHandler, imap: imapHandler, + intercom: intercomHandler, jira: jiraHandler, lemlist: lemlistHandler, linear: linearHandler, diff --git a/apps/sim/triggers/intercom/contact_created.ts b/apps/sim/triggers/intercom/contact_created.ts new file mode 100644 index 00000000000..f9602072c52 --- /dev/null +++ b/apps/sim/triggers/intercom/contact_created.ts @@ -0,0 +1,41 @@ +import { IntercomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIntercomContactOutputs, + buildIntercomExtraFields, + intercomSetupInstructions, + intercomTriggerOptions, +} from '@/triggers/intercom/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Intercom Contact Created Trigger + * + * Fires when a new lead is created in Intercom. + * Note: In Intercom, contact.created fires for leads only. + * For identified users, use the User Created trigger (user.created topic). + */ +export const intercomContactCreatedTrigger: TriggerConfig = { + id: 'intercom_contact_created', + name: 'Intercom Contact Created', + provider: 'intercom', + description: 'Trigger workflow when a new lead is created in Intercom', + version: '1.0.0', + icon: IntercomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'intercom_contact_created', + triggerOptions: intercomTriggerOptions, + setupInstructions: intercomSetupInstructions('contact.created'), + extraFields: buildIntercomExtraFields('intercom_contact_created'), + }), + + outputs: buildIntercomContactOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/intercom/conversation_closed.ts b/apps/sim/triggers/intercom/conversation_closed.ts new file mode 100644 index 00000000000..f8b8e23d5c7 --- /dev/null +++ b/apps/sim/triggers/intercom/conversation_closed.ts @@ -0,0 +1,39 @@ +import { IntercomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIntercomConversationOutputs, + buildIntercomExtraFields, + intercomSetupInstructions, + intercomTriggerOptions, +} from '@/triggers/intercom/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Intercom Conversation Closed Trigger + * + * Fires when an admin closes a conversation. + */ +export const intercomConversationClosedTrigger: TriggerConfig = { + id: 'intercom_conversation_closed', + name: 'Intercom Conversation Closed', + provider: 'intercom', + description: 'Trigger workflow when a conversation is closed in Intercom', + version: '1.0.0', + icon: IntercomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'intercom_conversation_closed', + triggerOptions: intercomTriggerOptions, + setupInstructions: intercomSetupInstructions('conversation.admin.closed'), + extraFields: buildIntercomExtraFields('intercom_conversation_closed'), + }), + + outputs: buildIntercomConversationOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/intercom/conversation_created.ts b/apps/sim/triggers/intercom/conversation_created.ts new file mode 100644 index 00000000000..0d18273f760 --- /dev/null +++ b/apps/sim/triggers/intercom/conversation_created.ts @@ -0,0 +1,43 @@ +import { IntercomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIntercomConversationOutputs, + buildIntercomExtraFields, + intercomSetupInstructions, + intercomTriggerOptions, +} from '@/triggers/intercom/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Intercom Conversation Created Trigger + * + * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. + * Fires when a user/lead starts a new conversation or an admin initiates a 1:1 conversation. + */ +export const intercomConversationCreatedTrigger: TriggerConfig = { + id: 'intercom_conversation_created', + name: 'Intercom Conversation Created', + provider: 'intercom', + description: 'Trigger workflow when a new conversation is created in Intercom', + version: '1.0.0', + icon: IntercomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'intercom_conversation_created', + triggerOptions: intercomTriggerOptions, + includeDropdown: true, + setupInstructions: intercomSetupInstructions( + 'conversation.user.created and/or conversation.admin.single.created' + ), + extraFields: buildIntercomExtraFields('intercom_conversation_created'), + }), + + outputs: buildIntercomConversationOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/intercom/conversation_reply.ts b/apps/sim/triggers/intercom/conversation_reply.ts new file mode 100644 index 00000000000..df621517ee6 --- /dev/null +++ b/apps/sim/triggers/intercom/conversation_reply.ts @@ -0,0 +1,41 @@ +import { IntercomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIntercomConversationOutputs, + buildIntercomExtraFields, + intercomSetupInstructions, + intercomTriggerOptions, +} from '@/triggers/intercom/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Intercom Conversation Reply Trigger + * + * Fires when a user, lead, or admin replies to a conversation. + */ +export const intercomConversationReplyTrigger: TriggerConfig = { + id: 'intercom_conversation_reply', + name: 'Intercom Conversation Reply', + provider: 'intercom', + description: 'Trigger workflow when someone replies to an Intercom conversation', + version: '1.0.0', + icon: IntercomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'intercom_conversation_reply', + triggerOptions: intercomTriggerOptions, + setupInstructions: intercomSetupInstructions( + 'conversation.user.replied and/or conversation.admin.replied' + ), + extraFields: buildIntercomExtraFields('intercom_conversation_reply'), + }), + + outputs: buildIntercomConversationOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/intercom/index.ts b/apps/sim/triggers/intercom/index.ts new file mode 100644 index 00000000000..b465a9d42f5 --- /dev/null +++ b/apps/sim/triggers/intercom/index.ts @@ -0,0 +1,6 @@ +export { intercomContactCreatedTrigger } from './contact_created' +export { intercomConversationClosedTrigger } from './conversation_closed' +export { intercomConversationCreatedTrigger } from './conversation_created' +export { intercomConversationReplyTrigger } from './conversation_reply' +export { intercomUserCreatedTrigger } from './user_created' +export { intercomWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/intercom/user_created.ts b/apps/sim/triggers/intercom/user_created.ts new file mode 100644 index 00000000000..0c62f53adaf --- /dev/null +++ b/apps/sim/triggers/intercom/user_created.ts @@ -0,0 +1,41 @@ +import { IntercomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIntercomContactOutputs, + buildIntercomExtraFields, + intercomSetupInstructions, + intercomTriggerOptions, +} from '@/triggers/intercom/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Intercom User Created Trigger + * + * Fires when a new identified user is created in Intercom. + * Note: In Intercom, user.created fires for identified users only. + * For anonymous leads, use the Contact Created trigger (contact.created topic). + */ +export const intercomUserCreatedTrigger: TriggerConfig = { + id: 'intercom_user_created', + name: 'Intercom User Created', + provider: 'intercom', + description: 'Trigger workflow when a new user is created in Intercom', + version: '1.0.0', + icon: IntercomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'intercom_user_created', + triggerOptions: intercomTriggerOptions, + setupInstructions: intercomSetupInstructions('user.created'), + extraFields: buildIntercomExtraFields('intercom_user_created'), + }), + + outputs: buildIntercomContactOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/intercom/utils.ts b/apps/sim/triggers/intercom/utils.ts new file mode 100644 index 00000000000..04ef4b883b2 --- /dev/null +++ b/apps/sim/triggers/intercom/utils.ts @@ -0,0 +1,128 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Dropdown options for the Intercom trigger type selector. + */ +export const intercomTriggerOptions = [ + { label: 'Conversation Created', id: 'intercom_conversation_created' }, + { label: 'Conversation Reply', id: 'intercom_conversation_reply' }, + { label: 'Conversation Closed', id: 'intercom_conversation_closed' }, + { label: 'Contact Created', id: 'intercom_contact_created' }, + { label: 'User Created', id: 'intercom_user_created' }, + { label: 'All Events', id: 'intercom_webhook' }, +] + +/** + * Generates HTML setup instructions for Intercom webhook triggers. + */ +export function intercomSetupInstructions(eventType: string): string { + const instructions = [ + 'Copy the Webhook URL above.', + 'Go to your Intercom Developer Hub.', + 'Select your app, then go to Webhooks.', + 'Paste the webhook URL into the Endpoint URL field.', + `Select the ${eventType} topic(s).`, + "Copy your app's Client Secret from the app's Basic Information page and paste it into the Webhook Secret field above (recommended for security).", + 'Save the webhook configuration.', + 'Deploy your workflow to activate the trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Extra fields for Intercom triggers (webhook secret for signature verification). + */ +export function buildIntercomExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Enter your Intercom app Client Secret', + description: + "Your app's Client Secret from the Developer Hub. Used to verify webhook authenticity via X-Hub-Signature.", + password: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Maps trigger IDs to the Intercom webhook topics they should match. + */ +export const INTERCOM_TRIGGER_TOPIC_MAP: Record = { + intercom_conversation_created: ['conversation.user.created', 'conversation.admin.single.created'], + intercom_conversation_reply: ['conversation.user.replied', 'conversation.admin.replied'], + intercom_conversation_closed: ['conversation.admin.closed'], + intercom_contact_created: ['contact.created'], + intercom_user_created: ['user.created'], + intercom_webhook: [], // Empty = accept all events +} + +/** + * Checks if an Intercom webhook event matches the configured trigger. + */ +export function isIntercomEventMatch(triggerId: string, topic: string): boolean { + const allowedTopics = INTERCOM_TRIGGER_TOPIC_MAP[triggerId] + if (allowedTopics === undefined) return false + if (allowedTopics.length === 0) { + return true + } + return allowedTopics.includes(topic) +} + +/** + * Shared base outputs for all Intercom webhook triggers. + */ +function buildIntercomBaseOutputs(dataDescription: string): Record { + return { + topic: { type: 'string', description: 'The webhook topic (e.g., conversation.user.created)' }, + id: { type: 'string', description: 'Unique notification ID' }, + app_id: { type: 'string', description: 'Your Intercom app ID' }, + created_at: { type: 'number', description: 'Unix timestamp when the event occurred' }, + delivery_attempts: { + type: 'number', + description: 'Number of delivery attempts for this notification', + }, + first_sent_at: { + type: 'number', + description: 'Unix timestamp of first delivery attempt', + }, + data: { type: 'json', description: dataDescription }, + } as Record +} + +/** + * Build outputs for Intercom conversation triggers. + */ +export function buildIntercomConversationOutputs(): Record { + return buildIntercomBaseOutputs( + 'Event data containing the conversation object. Access via data.item for conversation details including id, state, open, assignee, contacts, conversation_parts, tags, and source' + ) +} + +/** + * Build outputs for Intercom contact triggers. + */ +export function buildIntercomContactOutputs(): Record { + return buildIntercomBaseOutputs( + 'Event data containing the contact object. Access via data.item for contact details including id, role, email, name, phone, external_id, custom_attributes, location, avatar, tags, companies, and timestamps' + ) +} + +/** + * Build outputs for the generic Intercom webhook trigger. + */ +export function buildIntercomGenericOutputs(): Record { + return buildIntercomBaseOutputs( + 'Event data containing the affected object. Access via data.item for the resource (conversation, contact, company, ticket, etc.)' + ) +} diff --git a/apps/sim/triggers/intercom/webhook.ts b/apps/sim/triggers/intercom/webhook.ts new file mode 100644 index 00000000000..ded87c77ca8 --- /dev/null +++ b/apps/sim/triggers/intercom/webhook.ts @@ -0,0 +1,41 @@ +import { IntercomIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildIntercomExtraFields, + buildIntercomGenericOutputs, + intercomSetupInstructions, + intercomTriggerOptions, +} from '@/triggers/intercom/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Intercom Generic Webhook Trigger + * + * Accepts all Intercom webhook events. + */ +export const intercomWebhookTrigger: TriggerConfig = { + id: 'intercom_webhook', + name: 'Intercom Webhook (All Events)', + provider: 'intercom', + description: 'Trigger workflow on any Intercom webhook event', + version: '1.0.0', + icon: IntercomIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'intercom_webhook', + triggerOptions: intercomTriggerOptions, + setupInstructions: intercomSetupInstructions( + 'events you want to receive (conversation, contact, user, company, ticket, etc.)' + ), + extraFields: buildIntercomExtraFields('intercom_webhook'), + }), + + outputs: buildIntercomGenericOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index 131a0117770..eea378bfb99 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -131,6 +131,14 @@ import { hubspotWebhookTrigger, } from '@/triggers/hubspot' import { imapPollingTrigger } from '@/triggers/imap' +import { + intercomContactCreatedTrigger, + intercomConversationClosedTrigger, + intercomConversationCreatedTrigger, + intercomConversationReplyTrigger, + intercomUserCreatedTrigger, + intercomWebhookTrigger, +} from '@/triggers/intercom' import { jiraIssueCommentedTrigger, jiraIssueCreatedTrigger, @@ -381,4 +389,10 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { hubspot_ticket_restored: hubspotTicketRestoredTrigger, hubspot_webhook: hubspotWebhookTrigger, imap_poller: imapPollingTrigger, + intercom_conversation_created: intercomConversationCreatedTrigger, + intercom_conversation_reply: intercomConversationReplyTrigger, + intercom_conversation_closed: intercomConversationClosedTrigger, + intercom_contact_created: intercomContactCreatedTrigger, + intercom_user_created: intercomUserCreatedTrigger, + intercom_webhook: intercomWebhookTrigger, } From 7ea06931c8f5cd70550455841535769582e42c95 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 11:59:18 -0700 Subject: [PATCH 08/32] feat(triggers): add Greenhouse webhook triggers (#3985) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(triggers): add Greenhouse webhook triggers Add 8 webhook triggers for Greenhouse ATS events: - Candidate Hired, New Application, Stage Change, Rejected - Offer Created, Job Created, Job Updated - Generic Webhook (all events) Includes event filtering via provider handler registry and output schemas matching actual Greenhouse webhook payload structures. * fix(triggers): address PR review feedback for Greenhouse triggers - Fix rejection_reason.type key collision with mock payload generator by renaming to reason_type - Replace dynamic import with static import in matchEvent handler - Add HMAC-SHA256 signature verification via createHmacVerifier - Add secretKey extra field to all trigger subBlocks - Extract shared buildJobPayload helper to deduplicate job outputs * fix(triggers): align rejection_reason output with actual Greenhouse payload Reverted reason_type rename — instead flattened rejection_reason to JSON type since TriggerOutput's type?: string conflicts with nested type keys. Also hardened processOutputField to check typeof type === 'string' before treating an object as a leaf node, preventing this class of bug for future triggers. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- apps/sim/blocks/blocks/greenhouse.ts | 26 ++ apps/sim/lib/webhooks/providers/greenhouse.ts | 80 +++++ apps/sim/lib/webhooks/providers/registry.ts | 2 + .../lib/workflows/triggers/trigger-utils.ts | 7 +- .../triggers/greenhouse/candidate_hired.ts | 41 +++ .../triggers/greenhouse/candidate_rejected.ts | 39 +++ .../greenhouse/candidate_stage_change.ts | 39 +++ apps/sim/triggers/greenhouse/index.ts | 8 + apps/sim/triggers/greenhouse/job_created.ts | 39 +++ apps/sim/triggers/greenhouse/job_updated.ts | 39 +++ .../triggers/greenhouse/new_application.ts | 39 +++ apps/sim/triggers/greenhouse/offer_created.ts | 39 +++ apps/sim/triggers/greenhouse/utils.ts | 326 ++++++++++++++++++ apps/sim/triggers/greenhouse/webhook.ts | 39 +++ apps/sim/triggers/registry.ts | 18 + 15 files changed, 780 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/webhooks/providers/greenhouse.ts create mode 100644 apps/sim/triggers/greenhouse/candidate_hired.ts create mode 100644 apps/sim/triggers/greenhouse/candidate_rejected.ts create mode 100644 apps/sim/triggers/greenhouse/candidate_stage_change.ts create mode 100644 apps/sim/triggers/greenhouse/index.ts create mode 100644 apps/sim/triggers/greenhouse/job_created.ts create mode 100644 apps/sim/triggers/greenhouse/job_updated.ts create mode 100644 apps/sim/triggers/greenhouse/new_application.ts create mode 100644 apps/sim/triggers/greenhouse/offer_created.ts create mode 100644 apps/sim/triggers/greenhouse/utils.ts create mode 100644 apps/sim/triggers/greenhouse/webhook.ts diff --git a/apps/sim/blocks/blocks/greenhouse.ts b/apps/sim/blocks/blocks/greenhouse.ts index 4df4963fca4..4df34ef87cb 100644 --- a/apps/sim/blocks/blocks/greenhouse.ts +++ b/apps/sim/blocks/blocks/greenhouse.ts @@ -1,6 +1,7 @@ import { GreenhouseIcon } from '@/components/icons' import { AuthMode, type BlockConfig, IntegrationType } from '@/blocks/types' import type { GreenhouseResponse } from '@/tools/greenhouse/types' +import { getTrigger } from '@/triggers' export const GreenhouseBlock: BlockConfig = { type: 'greenhouse', @@ -16,6 +17,20 @@ export const GreenhouseBlock: BlockConfig = { icon: GreenhouseIcon, authMode: AuthMode.ApiKey, + triggers: { + enabled: true, + available: [ + 'greenhouse_candidate_hired', + 'greenhouse_new_application', + 'greenhouse_candidate_stage_change', + 'greenhouse_candidate_rejected', + 'greenhouse_offer_created', + 'greenhouse_job_created', + 'greenhouse_job_updated', + 'greenhouse_webhook', + ], + }, + subBlocks: [ { id: 'operation', @@ -291,6 +306,17 @@ Return ONLY the ISO 8601 timestamp - no explanations, no extra text.`, required: true, password: true, }, + + // ── Trigger subBlocks ── + + ...getTrigger('greenhouse_candidate_hired').subBlocks, + ...getTrigger('greenhouse_new_application').subBlocks, + ...getTrigger('greenhouse_candidate_stage_change').subBlocks, + ...getTrigger('greenhouse_candidate_rejected').subBlocks, + ...getTrigger('greenhouse_offer_created').subBlocks, + ...getTrigger('greenhouse_job_created').subBlocks, + ...getTrigger('greenhouse_job_updated').subBlocks, + ...getTrigger('greenhouse_webhook').subBlocks, ], tools: { diff --git a/apps/sim/lib/webhooks/providers/greenhouse.ts b/apps/sim/lib/webhooks/providers/greenhouse.ts new file mode 100644 index 00000000000..65f3090dee8 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/greenhouse.ts @@ -0,0 +1,80 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' +import { isGreenhouseEventMatch } from '@/triggers/greenhouse/utils' + +const logger = createLogger('WebhookProvider:Greenhouse') + +/** + * Validates the Greenhouse HMAC-SHA256 signature. + * Greenhouse sends: `Signature: sha256 ` + */ +function validateGreenhouseSignature(secretKey: string, signature: string, body: string): boolean { + try { + if (!secretKey || !signature || !body) { + return false + } + const prefix = 'sha256 ' + if (!signature.startsWith(prefix)) { + return false + } + const providedDigest = signature.substring(prefix.length) + const computedDigest = crypto.createHmac('sha256', secretKey).update(body, 'utf8').digest('hex') + return safeCompare(computedDigest, providedDigest) + } catch { + logger.error('Error validating Greenhouse signature') + return false + } +} + +export const greenhouseHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'secretKey', + headerName: 'signature', + validateFn: validateGreenhouseSignature, + providerLabel: 'Greenhouse', + }), + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + action: b.action, + payload: b.payload || {}, + }, + } + }, + + async matchEvent({ webhook, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const b = body as Record + const action = b.action as string | undefined + + if (triggerId && triggerId !== 'greenhouse_webhook') { + if (!isGreenhouseEventMatch(triggerId, action || '')) { + logger.debug( + `[${requestId}] Greenhouse event mismatch for trigger ${triggerId}. Action: ${action}. Skipping execution.`, + { + webhookId: webhook.id, + triggerId, + receivedAction: action, + } + ) + + return NextResponse.json({ + message: 'Event type does not match trigger configuration. Ignoring.', + }) + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 1bea27ebc15..4c341297201 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -15,6 +15,7 @@ import { gmailHandler } from '@/lib/webhooks/providers/gmail' import { gongHandler } from '@/lib/webhooks/providers/gong' import { googleFormsHandler } from '@/lib/webhooks/providers/google-forms' import { grainHandler } from '@/lib/webhooks/providers/grain' +import { greenhouseHandler } from '@/lib/webhooks/providers/greenhouse' import { hubspotHandler } from '@/lib/webhooks/providers/hubspot' import { imapHandler } from '@/lib/webhooks/providers/imap' import { intercomHandler } from '@/lib/webhooks/providers/intercom' @@ -54,6 +55,7 @@ const PROVIDER_HANDLERS: Record = { google_forms: googleFormsHandler, fathom: fathomHandler, grain: grainHandler, + greenhouse: greenhouseHandler, hubspot: hubspotHandler, imap: imapHandler, intercom: intercomHandler, diff --git a/apps/sim/lib/workflows/triggers/trigger-utils.ts b/apps/sim/lib/workflows/triggers/trigger-utils.ts index 276e28ce889..f1ee5ce7450 100644 --- a/apps/sim/lib/workflows/triggers/trigger-utils.ts +++ b/apps/sim/lib/workflows/triggers/trigger-utils.ts @@ -74,7 +74,12 @@ function processOutputField(key: string, field: unknown, depth = 0, maxDepth = 1 return null } - if (field && typeof field === 'object' && 'type' in field) { + if ( + field && + typeof field === 'object' && + 'type' in field && + typeof (field as Record).type === 'string' + ) { const typedField = field as { type: string; description?: string } return generateMockValue(typedField.type, typedField.description, key) } diff --git a/apps/sim/triggers/greenhouse/candidate_hired.ts b/apps/sim/triggers/greenhouse/candidate_hired.ts new file mode 100644 index 00000000000..0805bddcbbe --- /dev/null +++ b/apps/sim/triggers/greenhouse/candidate_hired.ts @@ -0,0 +1,41 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCandidateHiredOutputs, + buildGreenhouseExtraFields, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Candidate Hired Trigger + * + * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. + * Fires when a candidate is marked as hired in Greenhouse. + */ +export const greenhouseCandidateHiredTrigger: TriggerConfig = { + id: 'greenhouse_candidate_hired', + name: 'Greenhouse Candidate Hired', + provider: 'greenhouse', + description: 'Trigger workflow when a candidate is hired', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_candidate_hired', + triggerOptions: greenhouseTriggerOptions, + includeDropdown: true, + setupInstructions: greenhouseSetupInstructions('Candidate Hired'), + extraFields: buildGreenhouseExtraFields('greenhouse_candidate_hired'), + }), + + outputs: buildCandidateHiredOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/candidate_rejected.ts b/apps/sim/triggers/greenhouse/candidate_rejected.ts new file mode 100644 index 00000000000..52f10c038e4 --- /dev/null +++ b/apps/sim/triggers/greenhouse/candidate_rejected.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCandidateRejectedOutputs, + buildGreenhouseExtraFields, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Candidate Rejected Trigger + * + * Fires when a candidate is rejected from a position. + */ +export const greenhouseCandidateRejectedTrigger: TriggerConfig = { + id: 'greenhouse_candidate_rejected', + name: 'Greenhouse Candidate Rejected', + provider: 'greenhouse', + description: 'Trigger workflow when a candidate is rejected', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_candidate_rejected', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Candidate Rejected'), + extraFields: buildGreenhouseExtraFields('greenhouse_candidate_rejected'), + }), + + outputs: buildCandidateRejectedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/candidate_stage_change.ts b/apps/sim/triggers/greenhouse/candidate_stage_change.ts new file mode 100644 index 00000000000..d9f9505f40d --- /dev/null +++ b/apps/sim/triggers/greenhouse/candidate_stage_change.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCandidateStageChangeOutputs, + buildGreenhouseExtraFields, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Candidate Stage Change Trigger + * + * Fires when a candidate moves to a different interview stage. + */ +export const greenhouseCandidateStageChangeTrigger: TriggerConfig = { + id: 'greenhouse_candidate_stage_change', + name: 'Greenhouse Candidate Stage Change', + provider: 'greenhouse', + description: 'Trigger workflow when a candidate changes interview stages', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_candidate_stage_change', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Candidate Stage Change'), + extraFields: buildGreenhouseExtraFields('greenhouse_candidate_stage_change'), + }), + + outputs: buildCandidateStageChangeOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/index.ts b/apps/sim/triggers/greenhouse/index.ts new file mode 100644 index 00000000000..e9508aa154a --- /dev/null +++ b/apps/sim/triggers/greenhouse/index.ts @@ -0,0 +1,8 @@ +export { greenhouseCandidateHiredTrigger } from './candidate_hired' +export { greenhouseCandidateRejectedTrigger } from './candidate_rejected' +export { greenhouseCandidateStageChangeTrigger } from './candidate_stage_change' +export { greenhouseJobCreatedTrigger } from './job_created' +export { greenhouseJobUpdatedTrigger } from './job_updated' +export { greenhouseNewApplicationTrigger } from './new_application' +export { greenhouseOfferCreatedTrigger } from './offer_created' +export { greenhouseWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/greenhouse/job_created.ts b/apps/sim/triggers/greenhouse/job_created.ts new file mode 100644 index 00000000000..8cfefd33a12 --- /dev/null +++ b/apps/sim/triggers/greenhouse/job_created.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildJobCreatedOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Job Created Trigger + * + * Fires when a new job posting is created. + */ +export const greenhouseJobCreatedTrigger: TriggerConfig = { + id: 'greenhouse_job_created', + name: 'Greenhouse Job Created', + provider: 'greenhouse', + description: 'Trigger workflow when a new job is created', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_job_created', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Job Created'), + extraFields: buildGreenhouseExtraFields('greenhouse_job_created'), + }), + + outputs: buildJobCreatedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/job_updated.ts b/apps/sim/triggers/greenhouse/job_updated.ts new file mode 100644 index 00000000000..c669ef22ec0 --- /dev/null +++ b/apps/sim/triggers/greenhouse/job_updated.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildJobUpdatedOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Job Updated Trigger + * + * Fires when a job posting is updated. + */ +export const greenhouseJobUpdatedTrigger: TriggerConfig = { + id: 'greenhouse_job_updated', + name: 'Greenhouse Job Updated', + provider: 'greenhouse', + description: 'Trigger workflow when a job is updated', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_job_updated', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Job Updated'), + extraFields: buildGreenhouseExtraFields('greenhouse_job_updated'), + }), + + outputs: buildJobUpdatedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/new_application.ts b/apps/sim/triggers/greenhouse/new_application.ts new file mode 100644 index 00000000000..933cd624e14 --- /dev/null +++ b/apps/sim/triggers/greenhouse/new_application.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildNewApplicationOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse New Application Trigger + * + * Fires when a new candidate application is submitted. + */ +export const greenhouseNewApplicationTrigger: TriggerConfig = { + id: 'greenhouse_new_application', + name: 'Greenhouse New Application', + provider: 'greenhouse', + description: 'Trigger workflow when a new application is submitted', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_new_application', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('New Candidate Application'), + extraFields: buildGreenhouseExtraFields('greenhouse_new_application'), + }), + + outputs: buildNewApplicationOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/offer_created.ts b/apps/sim/triggers/greenhouse/offer_created.ts new file mode 100644 index 00000000000..7567a9adb63 --- /dev/null +++ b/apps/sim/triggers/greenhouse/offer_created.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildOfferCreatedOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Offer Created Trigger + * + * Fires when a new offer is created for a candidate. + */ +export const greenhouseOfferCreatedTrigger: TriggerConfig = { + id: 'greenhouse_offer_created', + name: 'Greenhouse Offer Created', + provider: 'greenhouse', + description: 'Trigger workflow when a new offer is created', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_offer_created', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('Offer Created'), + extraFields: buildGreenhouseExtraFields('greenhouse_offer_created'), + }), + + outputs: buildOfferCreatedOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/greenhouse/utils.ts b/apps/sim/triggers/greenhouse/utils.ts new file mode 100644 index 00000000000..15972379e03 --- /dev/null +++ b/apps/sim/triggers/greenhouse/utils.ts @@ -0,0 +1,326 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Dropdown options for the Greenhouse trigger type selector. + */ +export const greenhouseTriggerOptions = [ + { label: 'Candidate Hired', id: 'greenhouse_candidate_hired' }, + { label: 'New Application', id: 'greenhouse_new_application' }, + { label: 'Candidate Stage Change', id: 'greenhouse_candidate_stage_change' }, + { label: 'Candidate Rejected', id: 'greenhouse_candidate_rejected' }, + { label: 'Offer Created', id: 'greenhouse_offer_created' }, + { label: 'Job Created', id: 'greenhouse_job_created' }, + { label: 'Job Updated', id: 'greenhouse_job_updated' }, + { label: 'Generic Webhook (All Events)', id: 'greenhouse_webhook' }, +] + +/** + * Maps trigger IDs to Greenhouse webhook action strings. + * Used for event filtering in the webhook processor. + */ +export const GREENHOUSE_EVENT_MAP: Record = { + greenhouse_candidate_hired: 'hire_candidate', + greenhouse_new_application: 'new_candidate_application', + greenhouse_candidate_stage_change: 'candidate_stage_change', + greenhouse_candidate_rejected: 'reject_candidate', + greenhouse_offer_created: 'offer_created', + greenhouse_job_created: 'job_created', + greenhouse_job_updated: 'job_updated', +} + +/** + * Checks whether a Greenhouse webhook payload matches the configured trigger. + */ +export function isGreenhouseEventMatch(triggerId: string, action: string): boolean { + const expectedAction = GREENHOUSE_EVENT_MAP[triggerId] + if (!expectedAction) { + return true + } + return action === expectedAction +} + +/** + * Builds extra fields for Greenhouse triggers. + * Includes an optional secret key for HMAC signature verification. + */ +export function buildGreenhouseExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'secretKey', + title: 'Secret Key (Optional)', + type: 'short-input', + placeholder: 'Enter the same secret key configured in Greenhouse', + description: 'Used to verify webhook signatures via HMAC-SHA256.', + password: true, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Generates HTML setup instructions for Greenhouse webhooks. + * Webhooks are manually configured in the Greenhouse admin panel. + */ +export function greenhouseSetupInstructions(eventType: string): string { + const instructions = [ + 'Copy the Webhook URL above.', + 'In Greenhouse, go to Configure > Dev Center > Webhooks.', + 'Click Create New Webhook.', + 'Paste the Webhook URL into the Endpoint URL field.', + 'Enter a Secret Key for signature verification (optional).', + `Under When, select the ${eventType} event.`, + 'Click Create Webhook to save.', + 'Click "Save" above to activate your trigger.', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Build outputs for hire_candidate events. + * Greenhouse nests candidate inside application: payload.application.candidate + * Uses both singular `job` (deprecated) and `jobs` array. + */ +export function buildCandidateHiredOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (hire_candidate)' }, + payload: { + application: { + id: { type: 'number', description: 'Application ID' }, + status: { type: 'string', description: 'Application status' }, + prospect: { type: 'boolean', description: 'Whether the applicant is a prospect' }, + applied_at: { type: 'string', description: 'When the application was submitted' }, + url: { type: 'string', description: 'Application URL in Greenhouse' }, + current_stage: { + id: { type: 'number', description: 'Current stage ID' }, + name: { type: 'string', description: 'Current stage name' }, + }, + candidate: { + id: { type: 'number', description: 'Candidate ID' }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + title: { type: 'string', description: 'Current title' }, + company: { type: 'string', description: 'Current company' }, + email_addresses: { type: 'json', description: 'Email addresses' }, + phone_numbers: { type: 'json', description: 'Phone numbers' }, + recruiter: { type: 'json', description: 'Assigned recruiter' }, + coordinator: { type: 'json', description: 'Assigned coordinator' }, + }, + jobs: { type: 'json', description: 'Associated jobs (array)' }, + source: { + id: { type: 'number', description: 'Source ID' }, + public_name: { type: 'string', description: 'Source name' }, + }, + offer: { + id: { type: 'number', description: 'Offer ID' }, + version: { type: 'number', description: 'Offer version' }, + starts_at: { type: 'string', description: 'Offer start date' }, + custom_fields: { type: 'json', description: 'Offer custom fields' }, + }, + custom_fields: { type: 'json', description: 'Application custom fields' }, + }, + }, + } as Record +} + +/** + * Build outputs for new_candidate_application events. + * Candidate is nested inside application: payload.application.candidate + */ +export function buildNewApplicationOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (new_candidate_application)' }, + payload: { + application: { + id: { type: 'number', description: 'Application ID' }, + status: { type: 'string', description: 'Application status' }, + prospect: { type: 'boolean', description: 'Whether the applicant is a prospect' }, + applied_at: { type: 'string', description: 'When the application was submitted' }, + url: { type: 'string', description: 'Application URL in Greenhouse' }, + current_stage: { + id: { type: 'number', description: 'Current stage ID' }, + name: { type: 'string', description: 'Current stage name' }, + }, + candidate: { + id: { type: 'number', description: 'Candidate ID' }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + title: { type: 'string', description: 'Current title' }, + company: { type: 'string', description: 'Current company' }, + created_at: { type: 'string', description: 'When the candidate was created' }, + email_addresses: { type: 'json', description: 'Email addresses' }, + phone_numbers: { type: 'json', description: 'Phone numbers' }, + tags: { type: 'json', description: 'Candidate tags' }, + }, + jobs: { type: 'json', description: 'Associated jobs (array)' }, + source: { + id: { type: 'number', description: 'Source ID' }, + public_name: { type: 'string', description: 'Source name' }, + }, + answers: { type: 'json', description: 'Application question answers' }, + attachments: { type: 'json', description: 'Application attachments' }, + custom_fields: { type: 'json', description: 'Application custom fields' }, + }, + }, + } as Record +} + +/** + * Build outputs for candidate_stage_change events. + * Candidate is nested inside application: payload.application.candidate + */ +export function buildCandidateStageChangeOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (candidate_stage_change)' }, + payload: { + application: { + id: { type: 'number', description: 'Application ID' }, + status: { type: 'string', description: 'Application status' }, + prospect: { type: 'boolean', description: 'Whether the applicant is a prospect' }, + applied_at: { type: 'string', description: 'When the application was submitted' }, + url: { type: 'string', description: 'Application URL in Greenhouse' }, + current_stage: { + id: { type: 'number', description: 'Current stage ID' }, + name: { type: 'string', description: 'Current stage name' }, + interviews: { type: 'json', description: 'Interviews in this stage' }, + }, + candidate: { + id: { type: 'number', description: 'Candidate ID' }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + title: { type: 'string', description: 'Current title' }, + company: { type: 'string', description: 'Current company' }, + email_addresses: { type: 'json', description: 'Email addresses' }, + phone_numbers: { type: 'json', description: 'Phone numbers' }, + }, + jobs: { type: 'json', description: 'Associated jobs (array)' }, + source: { + id: { type: 'number', description: 'Source ID' }, + public_name: { type: 'string', description: 'Source name' }, + }, + custom_fields: { type: 'json', description: 'Application custom fields' }, + }, + }, + } as Record +} + +/** + * Build outputs for reject_candidate events. + * Candidate is nested inside application: payload.application.candidate + */ +export function buildCandidateRejectedOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (reject_candidate)' }, + payload: { + application: { + id: { type: 'number', description: 'Application ID' }, + status: { type: 'string', description: 'Application status (rejected)' }, + prospect: { type: 'boolean', description: 'Whether the applicant is a prospect' }, + applied_at: { type: 'string', description: 'When the application was submitted' }, + rejected_at: { type: 'string', description: 'When the candidate was rejected' }, + url: { type: 'string', description: 'Application URL in Greenhouse' }, + current_stage: { + id: { type: 'number', description: 'Stage ID where rejected' }, + name: { type: 'string', description: 'Stage name where rejected' }, + }, + candidate: { + id: { type: 'number', description: 'Candidate ID' }, + first_name: { type: 'string', description: 'First name' }, + last_name: { type: 'string', description: 'Last name' }, + email_addresses: { type: 'json', description: 'Email addresses' }, + phone_numbers: { type: 'json', description: 'Phone numbers' }, + }, + jobs: { type: 'json', description: 'Associated jobs (array)' }, + rejection_reason: { + type: 'json', + description: 'Rejection reason object with id, name, and type fields', + }, + rejection_details: { type: 'json', description: 'Rejection details with custom fields' }, + custom_fields: { type: 'json', description: 'Application custom fields' }, + }, + }, + } as Record +} + +/** + * Build outputs for offer_created events. + * Offer payload is flat under payload (not nested under payload.offer). + */ +export function buildOfferCreatedOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (offer_created)' }, + payload: { + id: { type: 'number', description: 'Offer ID' }, + application_id: { type: 'number', description: 'Associated application ID' }, + job_id: { type: 'number', description: 'Associated job ID' }, + user_id: { type: 'number', description: 'User who created the offer' }, + version: { type: 'number', description: 'Offer version number' }, + sent_on: { type: 'string', description: 'When the offer was sent' }, + resolved_at: { type: 'string', description: 'When the offer was resolved' }, + start_date: { type: 'string', description: 'Offer start date' }, + notes: { type: 'string', description: 'Offer notes' }, + offer_status: { type: 'string', description: 'Offer status' }, + custom_fields: { type: 'json', description: 'Custom field values' }, + }, + } as Record +} + +/** + * Shared job payload shape used by both job_created and job_updated events. + */ +function buildJobPayload(): Record { + return { + id: { type: 'number', description: 'Job ID' }, + name: { type: 'string', description: 'Job title' }, + requisition_id: { type: 'string', description: 'Requisition ID' }, + status: { type: 'string', description: 'Job status (open, closed, draft)' }, + confidential: { type: 'boolean', description: 'Whether the job is confidential' }, + created_at: { type: 'string', description: 'When the job was created' }, + opened_at: { type: 'string', description: 'When the job was opened' }, + closed_at: { type: 'string', description: 'When the job was closed' }, + departments: { type: 'json', description: 'Associated departments' }, + offices: { type: 'json', description: 'Associated offices' }, + hiring_team: { type: 'json', description: 'Hiring team (managers, recruiters, etc.)' }, + openings: { type: 'json', description: 'Job openings' }, + custom_fields: { type: 'json', description: 'Custom field values' }, + } as Record +} + +/** + * Build outputs for job_created events. + * Job data is nested under payload.job. + */ +export function buildJobCreatedOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (job_created)' }, + payload: { job: buildJobPayload() }, + } as Record +} + +/** + * Build outputs for job_updated events. + * Same structure as job_created. + */ +export function buildJobUpdatedOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type (job_updated)' }, + payload: { job: buildJobPayload() }, + } as Record +} + +/** + * Build outputs for generic webhook (all events). + */ +export function buildWebhookOutputs(): Record { + return { + action: { type: 'string', description: 'The webhook event type' }, + payload: { type: 'json', description: 'Full event payload' }, + } as Record +} diff --git a/apps/sim/triggers/greenhouse/webhook.ts b/apps/sim/triggers/greenhouse/webhook.ts new file mode 100644 index 00000000000..de436a89748 --- /dev/null +++ b/apps/sim/triggers/greenhouse/webhook.ts @@ -0,0 +1,39 @@ +import { GreenhouseIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGreenhouseExtraFields, + buildWebhookOutputs, + greenhouseSetupInstructions, + greenhouseTriggerOptions, +} from '@/triggers/greenhouse/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Greenhouse Generic Webhook Trigger + * + * Accepts all Greenhouse webhook events without filtering. + */ +export const greenhouseWebhookTrigger: TriggerConfig = { + id: 'greenhouse_webhook', + name: 'Greenhouse Webhook (All Events)', + provider: 'greenhouse', + description: 'Trigger workflow on any Greenhouse webhook event', + version: '1.0.0', + icon: GreenhouseIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'greenhouse_webhook', + triggerOptions: greenhouseTriggerOptions, + setupInstructions: greenhouseSetupInstructions('All Events'), + extraFields: buildGreenhouseExtraFields('greenhouse_webhook'), + }), + + outputs: buildWebhookOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index eea378bfb99..ffd46f277e7 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -101,6 +101,16 @@ import { grainStoryCreatedTrigger, grainWebhookTrigger, } from '@/triggers/grain' +import { + greenhouseCandidateHiredTrigger, + greenhouseCandidateRejectedTrigger, + greenhouseCandidateStageChangeTrigger, + greenhouseJobCreatedTrigger, + greenhouseJobUpdatedTrigger, + greenhouseNewApplicationTrigger, + greenhouseOfferCreatedTrigger, + greenhouseWebhookTrigger, +} from '@/triggers/greenhouse' import { hubspotCompanyCreatedTrigger, hubspotCompanyDeletedTrigger, @@ -274,6 +284,14 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { confluence_label_added: confluenceLabelAddedTrigger, confluence_label_removed: confluenceLabelRemovedTrigger, generic_webhook: genericWebhookTrigger, + greenhouse_candidate_hired: greenhouseCandidateHiredTrigger, + greenhouse_new_application: greenhouseNewApplicationTrigger, + greenhouse_candidate_stage_change: greenhouseCandidateStageChangeTrigger, + greenhouse_candidate_rejected: greenhouseCandidateRejectedTrigger, + greenhouse_offer_created: greenhouseOfferCreatedTrigger, + greenhouse_job_created: greenhouseJobCreatedTrigger, + greenhouse_job_updated: greenhouseJobUpdatedTrigger, + greenhouse_webhook: greenhouseWebhookTrigger, github_webhook: githubWebhookTrigger, github_issue_opened: githubIssueOpenedTrigger, github_issue_closed: githubIssueClosedTrigger, From 21e5b5c594c2c96bb03ec09944dc2e7ffaaa6715 Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 12:05:00 -0700 Subject: [PATCH 09/32] feat(triggers): add Notion webhook triggers (#3989) * feat(triggers): add Notion webhook triggers for all event types Add 9 Notion webhook triggers covering the full event lifecycle: - Page events: created, properties updated, content updated, deleted - Database events: created, schema updated, deleted - Comment events: created - Generic webhook trigger (all events) Implements provider handler with HMAC SHA-256 signature verification, event filtering via matchEvent, and structured input formatting. Co-Authored-By: Claude Opus 4.6 * fix(triggers): resolve type field collision in Notion trigger outputs Rename nested `type` fields to `entity_type`/`parent_type` to avoid collision with processOutputField's leaf node detection which checks `'type' in field`. Remove spread of author outputs into `authors` array which was overwriting `type: 'array'`. Co-Authored-By: Claude Opus 4.6 * fix(triggers): clarify Notion webhook signing secret vs verification_token Update placeholder and description to distinguish the signing secret (used for HMAC-SHA256 signature verification) from the verification_token (one-time challenge echoed during initial setup). Co-Authored-By: Claude Opus 4.6 * refactor(webhooks): use createHmacVerifier for Notion provider handler Replace inline verifyAuth boilerplate with createHmacVerifier utility, consistent with Linear, Ashby, Cal.com, Circleback, Confluence, and Fireflies providers. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- .../integrations/data/integrations.json | 50 ++++- apps/sim/blocks/blocks/notion.ts | 30 ++- apps/sim/lib/webhooks/providers/notion.ts | 100 +++++++++ apps/sim/lib/webhooks/providers/registry.ts | 2 + apps/sim/triggers/notion/comment_created.ts | 38 ++++ apps/sim/triggers/notion/database_created.ts | 38 ++++ apps/sim/triggers/notion/database_deleted.ts | 38 ++++ .../notion/database_schema_updated.ts | 40 ++++ apps/sim/triggers/notion/index.ts | 9 + .../triggers/notion/page_content_updated.ts | 40 ++++ apps/sim/triggers/notion/page_created.ts | 41 ++++ apps/sim/triggers/notion/page_deleted.ts | 38 ++++ .../notion/page_properties_updated.ts | 40 ++++ apps/sim/triggers/notion/utils.ts | 201 ++++++++++++++++++ apps/sim/triggers/notion/webhook.ts | 38 ++++ apps/sim/triggers/registry.ts | 20 ++ 16 files changed, 760 insertions(+), 3 deletions(-) create mode 100644 apps/sim/lib/webhooks/providers/notion.ts create mode 100644 apps/sim/triggers/notion/comment_created.ts create mode 100644 apps/sim/triggers/notion/database_created.ts create mode 100644 apps/sim/triggers/notion/database_deleted.ts create mode 100644 apps/sim/triggers/notion/database_schema_updated.ts create mode 100644 apps/sim/triggers/notion/index.ts create mode 100644 apps/sim/triggers/notion/page_content_updated.ts create mode 100644 apps/sim/triggers/notion/page_created.ts create mode 100644 apps/sim/triggers/notion/page_deleted.ts create mode 100644 apps/sim/triggers/notion/page_properties_updated.ts create mode 100644 apps/sim/triggers/notion/utils.ts create mode 100644 apps/sim/triggers/notion/webhook.ts diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json index 189e0a3b243..cb5d3cbdb41 100644 --- a/apps/sim/app/(landing)/integrations/data/integrations.json +++ b/apps/sim/app/(landing)/integrations/data/integrations.json @@ -8180,8 +8180,54 @@ "docsUrl": "https://docs.sim.ai/tools/notion", "operations": [], "operationCount": 0, - "triggers": [], - "triggerCount": 0, + "triggers": [ + { + "id": "notion_page_created", + "name": "Notion Page Created", + "description": "Trigger workflow when a new page is created in Notion" + }, + { + "id": "notion_page_properties_updated", + "name": "Notion Page Properties Updated", + "description": "Trigger workflow when page properties are modified in Notion" + }, + { + "id": "notion_page_content_updated", + "name": "Notion Page Content Updated", + "description": "Trigger workflow when page content is changed in Notion" + }, + { + "id": "notion_page_deleted", + "name": "Notion Page Deleted", + "description": "Trigger workflow when a page is deleted in Notion" + }, + { + "id": "notion_database_created", + "name": "Notion Database Created", + "description": "Trigger workflow when a new database is created in Notion" + }, + { + "id": "notion_database_schema_updated", + "name": "Notion Database Schema Updated", + "description": "Trigger workflow when a database schema is modified in Notion" + }, + { + "id": "notion_database_deleted", + "name": "Notion Database Deleted", + "description": "Trigger workflow when a database is deleted in Notion" + }, + { + "id": "notion_comment_created", + "name": "Notion Comment Created", + "description": "Trigger workflow when a comment or suggested edit is added in Notion" + }, + { + "id": "notion_webhook", + "name": "Notion Webhook (All Events)", + "description": "Trigger workflow on any Notion webhook event" + } + ], + "triggerCount": 9, "authType": "oauth", "category": "tools", "integrationType": "documents", diff --git a/apps/sim/blocks/blocks/notion.ts b/apps/sim/blocks/blocks/notion.ts index 82fae4c2c3f..34f5198aeeb 100644 --- a/apps/sim/blocks/blocks/notion.ts +++ b/apps/sim/blocks/blocks/notion.ts @@ -3,6 +3,7 @@ import type { BlockConfig } from '@/blocks/types' import { AuthMode, IntegrationType } from '@/blocks/types' import { createVersionedToolSelector } from '@/blocks/utils' import type { NotionResponse } from '@/tools/notion/types' +import { getTrigger } from '@/triggers' // Legacy block - hidden from toolbar export const NotionBlock: BlockConfig = { @@ -436,7 +437,34 @@ export const NotionV2Block: BlockConfig = { bgColor: '#181C1E', icon: NotionIcon, hideFromToolbar: false, - subBlocks: NotionBlock.subBlocks, + subBlocks: [ + ...NotionBlock.subBlocks, + + // Trigger subBlocks + ...getTrigger('notion_page_created').subBlocks, + ...getTrigger('notion_page_properties_updated').subBlocks, + ...getTrigger('notion_page_content_updated').subBlocks, + ...getTrigger('notion_page_deleted').subBlocks, + ...getTrigger('notion_database_created').subBlocks, + ...getTrigger('notion_database_schema_updated').subBlocks, + ...getTrigger('notion_database_deleted').subBlocks, + ...getTrigger('notion_comment_created').subBlocks, + ...getTrigger('notion_webhook').subBlocks, + ], + triggers: { + enabled: true, + available: [ + 'notion_page_created', + 'notion_page_properties_updated', + 'notion_page_content_updated', + 'notion_page_deleted', + 'notion_database_created', + 'notion_database_schema_updated', + 'notion_database_deleted', + 'notion_comment_created', + 'notion_webhook', + ], + }, tools: { access: [ 'notion_read_v2', diff --git a/apps/sim/lib/webhooks/providers/notion.ts b/apps/sim/lib/webhooks/providers/notion.ts new file mode 100644 index 00000000000..8155fc67084 --- /dev/null +++ b/apps/sim/lib/webhooks/providers/notion.ts @@ -0,0 +1,100 @@ +import crypto from 'crypto' +import { createLogger } from '@sim/logger' +import { NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' +import type { + EventMatchContext, + FormatInputContext, + FormatInputResult, + WebhookProviderHandler, +} from '@/lib/webhooks/providers/types' +import { createHmacVerifier } from '@/lib/webhooks/providers/utils' + +const logger = createLogger('WebhookProvider:Notion') + +/** + * Validates a Notion webhook signature using HMAC SHA-256. + * Notion sends X-Notion-Signature as "sha256=". + */ +function validateNotionSignature(secret: string, signature: string, body: string): boolean { + try { + if (!secret || !signature || !body) { + logger.warn('Notion signature validation missing required fields', { + hasSecret: !!secret, + hasSignature: !!signature, + hasBody: !!body, + }) + return false + } + + const providedHash = signature.startsWith('sha256=') ? signature.slice(7) : signature + const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') + + logger.debug('Notion signature comparison', { + computedSignature: `${computedHash.substring(0, 10)}...`, + providedSignature: `${providedHash.substring(0, 10)}...`, + computedLength: computedHash.length, + providedLength: providedHash.length, + match: computedHash === providedHash, + }) + + return safeCompare(computedHash, providedHash) + } catch (error) { + logger.error('Error validating Notion signature:', error) + return false + } +} + +export const notionHandler: WebhookProviderHandler = { + verifyAuth: createHmacVerifier({ + configKey: 'webhookSecret', + headerName: 'X-Notion-Signature', + validateFn: validateNotionSignature, + providerLabel: 'Notion', + }), + + async formatInput({ body }: FormatInputContext): Promise { + const b = body as Record + return { + input: { + id: b.id, + type: b.type, + timestamp: b.timestamp, + workspace_id: b.workspace_id, + workspace_name: b.workspace_name, + subscription_id: b.subscription_id, + integration_id: b.integration_id, + attempt_number: b.attempt_number, + authors: b.authors || [], + entity: b.entity || {}, + data: b.data || {}, + }, + } + }, + + async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) { + const triggerId = providerConfig.triggerId as string | undefined + const obj = body as Record + + if (triggerId && triggerId !== 'notion_webhook') { + const { isNotionPayloadMatch } = await import('@/triggers/notion/utils') + if (!isNotionPayloadMatch(triggerId, obj)) { + const eventType = obj.type as string | undefined + logger.debug( + `[${requestId}] Notion event mismatch for trigger ${triggerId}. Event: ${eventType}. Skipping execution.`, + { + webhookId: webhook.id, + workflowId: workflow.id, + triggerId, + receivedEvent: eventType, + } + ) + return NextResponse.json({ + message: 'Event type does not match trigger configuration. Ignoring.', + }) + } + } + + return true + }, +} diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts index 4c341297201..6b7d6d3d7eb 100644 --- a/apps/sim/lib/webhooks/providers/registry.ts +++ b/apps/sim/lib/webhooks/providers/registry.ts @@ -23,6 +23,7 @@ import { jiraHandler } from '@/lib/webhooks/providers/jira' import { lemlistHandler } from '@/lib/webhooks/providers/lemlist' import { linearHandler } from '@/lib/webhooks/providers/linear' import { microsoftTeamsHandler } from '@/lib/webhooks/providers/microsoft-teams' +import { notionHandler } from '@/lib/webhooks/providers/notion' import { outlookHandler } from '@/lib/webhooks/providers/outlook' import { resendHandler } from '@/lib/webhooks/providers/resend' import { rssHandler } from '@/lib/webhooks/providers/rss' @@ -64,6 +65,7 @@ const PROVIDER_HANDLERS: Record = { linear: linearHandler, resend: resendHandler, 'microsoft-teams': microsoftTeamsHandler, + notion: notionHandler, outlook: outlookHandler, rss: rssHandler, slack: slackHandler, diff --git a/apps/sim/triggers/notion/comment_created.ts b/apps/sim/triggers/notion/comment_created.ts new file mode 100644 index 00000000000..afa154b2d22 --- /dev/null +++ b/apps/sim/triggers/notion/comment_created.ts @@ -0,0 +1,38 @@ +import { NotionIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildCommentEventOutputs, + buildNotionExtraFields, + notionSetupInstructions, + notionTriggerOptions, +} from '@/triggers/notion/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Notion Comment Created Trigger + */ +export const notionCommentCreatedTrigger: TriggerConfig = { + id: 'notion_comment_created', + name: 'Notion Comment Created', + provider: 'notion', + description: 'Trigger workflow when a comment or suggested edit is added in Notion', + version: '1.0.0', + icon: NotionIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'notion_comment_created', + triggerOptions: notionTriggerOptions, + setupInstructions: notionSetupInstructions('comment.created'), + extraFields: buildNotionExtraFields('notion_comment_created'), + }), + + outputs: buildCommentEventOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Notion-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/notion/database_created.ts b/apps/sim/triggers/notion/database_created.ts new file mode 100644 index 00000000000..62d9d8cb133 --- /dev/null +++ b/apps/sim/triggers/notion/database_created.ts @@ -0,0 +1,38 @@ +import { NotionIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildDatabaseEventOutputs, + buildNotionExtraFields, + notionSetupInstructions, + notionTriggerOptions, +} from '@/triggers/notion/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Notion Database Created Trigger + */ +export const notionDatabaseCreatedTrigger: TriggerConfig = { + id: 'notion_database_created', + name: 'Notion Database Created', + provider: 'notion', + description: 'Trigger workflow when a new database is created in Notion', + version: '1.0.0', + icon: NotionIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'notion_database_created', + triggerOptions: notionTriggerOptions, + setupInstructions: notionSetupInstructions('database.created'), + extraFields: buildNotionExtraFields('notion_database_created'), + }), + + outputs: buildDatabaseEventOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Notion-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/notion/database_deleted.ts b/apps/sim/triggers/notion/database_deleted.ts new file mode 100644 index 00000000000..0bd05796adb --- /dev/null +++ b/apps/sim/triggers/notion/database_deleted.ts @@ -0,0 +1,38 @@ +import { NotionIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildDatabaseEventOutputs, + buildNotionExtraFields, + notionSetupInstructions, + notionTriggerOptions, +} from '@/triggers/notion/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Notion Database Deleted Trigger + */ +export const notionDatabaseDeletedTrigger: TriggerConfig = { + id: 'notion_database_deleted', + name: 'Notion Database Deleted', + provider: 'notion', + description: 'Trigger workflow when a database is deleted in Notion', + version: '1.0.0', + icon: NotionIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'notion_database_deleted', + triggerOptions: notionTriggerOptions, + setupInstructions: notionSetupInstructions('database.deleted'), + extraFields: buildNotionExtraFields('notion_database_deleted'), + }), + + outputs: buildDatabaseEventOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Notion-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/notion/database_schema_updated.ts b/apps/sim/triggers/notion/database_schema_updated.ts new file mode 100644 index 00000000000..85b3511f43b --- /dev/null +++ b/apps/sim/triggers/notion/database_schema_updated.ts @@ -0,0 +1,40 @@ +import { NotionIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildDatabaseEventOutputs, + buildNotionExtraFields, + notionSetupInstructions, + notionTriggerOptions, +} from '@/triggers/notion/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Notion Database Schema Updated Trigger + * + * Fires when a database schema (properties/columns) is modified. + */ +export const notionDatabaseSchemaUpdatedTrigger: TriggerConfig = { + id: 'notion_database_schema_updated', + name: 'Notion Database Schema Updated', + provider: 'notion', + description: 'Trigger workflow when a database schema is modified in Notion', + version: '1.0.0', + icon: NotionIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'notion_database_schema_updated', + triggerOptions: notionTriggerOptions, + setupInstructions: notionSetupInstructions('database.schema_updated'), + extraFields: buildNotionExtraFields('notion_database_schema_updated'), + }), + + outputs: buildDatabaseEventOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Notion-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/notion/index.ts b/apps/sim/triggers/notion/index.ts new file mode 100644 index 00000000000..b21334103a3 --- /dev/null +++ b/apps/sim/triggers/notion/index.ts @@ -0,0 +1,9 @@ +export { notionCommentCreatedTrigger } from './comment_created' +export { notionDatabaseCreatedTrigger } from './database_created' +export { notionDatabaseDeletedTrigger } from './database_deleted' +export { notionDatabaseSchemaUpdatedTrigger } from './database_schema_updated' +export { notionPageContentUpdatedTrigger } from './page_content_updated' +export { notionPageCreatedTrigger } from './page_created' +export { notionPageDeletedTrigger } from './page_deleted' +export { notionPagePropertiesUpdatedTrigger } from './page_properties_updated' +export { notionWebhookTrigger } from './webhook' diff --git a/apps/sim/triggers/notion/page_content_updated.ts b/apps/sim/triggers/notion/page_content_updated.ts new file mode 100644 index 00000000000..1fb7134ad4d --- /dev/null +++ b/apps/sim/triggers/notion/page_content_updated.ts @@ -0,0 +1,40 @@ +import { NotionIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildNotionExtraFields, + buildPageEventOutputs, + notionSetupInstructions, + notionTriggerOptions, +} from '@/triggers/notion/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Notion Page Content Updated Trigger + * + * Fires when page content changes. High-frequency events may be batched. + */ +export const notionPageContentUpdatedTrigger: TriggerConfig = { + id: 'notion_page_content_updated', + name: 'Notion Page Content Updated', + provider: 'notion', + description: 'Trigger workflow when page content is changed in Notion', + version: '1.0.0', + icon: NotionIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'notion_page_content_updated', + triggerOptions: notionTriggerOptions, + setupInstructions: notionSetupInstructions('page.content_updated'), + extraFields: buildNotionExtraFields('notion_page_content_updated'), + }), + + outputs: buildPageEventOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Notion-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/notion/page_created.ts b/apps/sim/triggers/notion/page_created.ts new file mode 100644 index 00000000000..da176d4ff53 --- /dev/null +++ b/apps/sim/triggers/notion/page_created.ts @@ -0,0 +1,41 @@ +import { NotionIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildNotionExtraFields, + buildPageEventOutputs, + notionSetupInstructions, + notionTriggerOptions, +} from '@/triggers/notion/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Notion Page Created Trigger + * + * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type. + */ +export const notionPageCreatedTrigger: TriggerConfig = { + id: 'notion_page_created', + name: 'Notion Page Created', + provider: 'notion', + description: 'Trigger workflow when a new page is created in Notion', + version: '1.0.0', + icon: NotionIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'notion_page_created', + triggerOptions: notionTriggerOptions, + includeDropdown: true, + setupInstructions: notionSetupInstructions('page.created'), + extraFields: buildNotionExtraFields('notion_page_created'), + }), + + outputs: buildPageEventOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Notion-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/notion/page_deleted.ts b/apps/sim/triggers/notion/page_deleted.ts new file mode 100644 index 00000000000..641fa0f3cb2 --- /dev/null +++ b/apps/sim/triggers/notion/page_deleted.ts @@ -0,0 +1,38 @@ +import { NotionIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildNotionExtraFields, + buildPageEventOutputs, + notionSetupInstructions, + notionTriggerOptions, +} from '@/triggers/notion/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Notion Page Deleted Trigger + */ +export const notionPageDeletedTrigger: TriggerConfig = { + id: 'notion_page_deleted', + name: 'Notion Page Deleted', + provider: 'notion', + description: 'Trigger workflow when a page is deleted in Notion', + version: '1.0.0', + icon: NotionIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'notion_page_deleted', + triggerOptions: notionTriggerOptions, + setupInstructions: notionSetupInstructions('page.deleted'), + extraFields: buildNotionExtraFields('notion_page_deleted'), + }), + + outputs: buildPageEventOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Notion-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/notion/page_properties_updated.ts b/apps/sim/triggers/notion/page_properties_updated.ts new file mode 100644 index 00000000000..76a578eec59 --- /dev/null +++ b/apps/sim/triggers/notion/page_properties_updated.ts @@ -0,0 +1,40 @@ +import { NotionIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildNotionExtraFields, + buildPageEventOutputs, + notionSetupInstructions, + notionTriggerOptions, +} from '@/triggers/notion/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Notion Page Properties Updated Trigger + * + * Fires when page properties (title, status, tags, etc.) are modified. + */ +export const notionPagePropertiesUpdatedTrigger: TriggerConfig = { + id: 'notion_page_properties_updated', + name: 'Notion Page Properties Updated', + provider: 'notion', + description: 'Trigger workflow when page properties are modified in Notion', + version: '1.0.0', + icon: NotionIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'notion_page_properties_updated', + triggerOptions: notionTriggerOptions, + setupInstructions: notionSetupInstructions('page.properties_updated'), + extraFields: buildNotionExtraFields('notion_page_properties_updated'), + }), + + outputs: buildPageEventOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Notion-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/notion/utils.ts b/apps/sim/triggers/notion/utils.ts new file mode 100644 index 00000000000..df8d7a4b5b2 --- /dev/null +++ b/apps/sim/triggers/notion/utils.ts @@ -0,0 +1,201 @@ +import type { SubBlockConfig } from '@/blocks/types' +import type { TriggerOutput } from '@/triggers/types' + +/** + * Dropdown options for the Notion trigger type selector. + */ +export const notionTriggerOptions = [ + { label: 'Page Created', id: 'notion_page_created' }, + { label: 'Page Properties Updated', id: 'notion_page_properties_updated' }, + { label: 'Page Content Updated', id: 'notion_page_content_updated' }, + { label: 'Page Deleted', id: 'notion_page_deleted' }, + { label: 'Database Created', id: 'notion_database_created' }, + { label: 'Database Schema Updated', id: 'notion_database_schema_updated' }, + { label: 'Database Deleted', id: 'notion_database_deleted' }, + { label: 'Comment Created', id: 'notion_comment_created' }, + { label: 'Generic Webhook (All Events)', id: 'notion_webhook' }, +] + +/** + * Generates HTML setup instructions for Notion webhook triggers. + * Notion webhooks must be configured manually through the integration settings UI. + */ +export function notionSetupInstructions(eventType: string): string { + const instructions = [ + 'Go to notion.so/profile/integrations and select your integration (or create one).', + 'Navigate to the Webhooks tab.', + 'Click "Create a subscription".', + 'Paste the Webhook URL above into the URL field.', + `Select the ${eventType} event type(s).`, + 'Notion will send a verification request. Copy the verification_token from the payload and paste it into the Notion UI to complete verification.', + 'Ensure the integration has access to the pages/databases you want to monitor (share them with the integration).', + ] + + return instructions + .map( + (instruction, index) => + `
${index + 1}. ${instruction}
` + ) + .join('') +} + +/** + * Extra fields for Notion triggers (no extra fields needed since setup is manual). + */ +export function buildNotionExtraFields(triggerId: string): SubBlockConfig[] { + return [ + { + id: 'webhookSecret', + title: 'Webhook Secret', + type: 'short-input', + placeholder: 'Enter your Notion webhook signing secret', + description: + 'The signing secret from your Notion integration settings page, used to verify X-Notion-Signature headers. This is separate from the verification_token used during initial setup.', + password: true, + required: false, + mode: 'trigger', + condition: { field: 'selectedTriggerId', value: triggerId }, + }, + ] +} + +/** + * Base webhook outputs common to all Notion triggers. + */ +function buildBaseOutputs(): Record { + return { + id: { type: 'string', description: 'Webhook event ID' }, + type: { + type: 'string', + description: 'Event type (e.g., page.created, database.schema_updated)', + }, + timestamp: { type: 'string', description: 'ISO 8601 timestamp of the event' }, + workspace_id: { type: 'string', description: 'Workspace ID where the event occurred' }, + workspace_name: { type: 'string', description: 'Workspace name' }, + subscription_id: { type: 'string', description: 'Webhook subscription ID' }, + integration_id: { type: 'string', description: 'Integration ID that received the event' }, + attempt_number: { type: 'number', description: 'Delivery attempt number' }, + } +} + +/** + * Entity output schema (the resource that was affected). + */ +function buildEntityOutputs(): Record { + return { + id: { type: 'string', description: 'Entity ID (page or database ID)' }, + entity_type: { type: 'string', description: 'Entity type (page or database)' }, + } +} + +/** + * Build outputs for page event triggers. + */ +export function buildPageEventOutputs(): Record { + return { + ...buildBaseOutputs(), + authors: { + type: 'array', + description: 'Array of users who triggered the event', + }, + entity: buildEntityOutputs(), + data: { + parent: { + id: { type: 'string', description: 'Parent page or database ID' }, + parent_type: { type: 'string', description: 'Parent type (database, page, workspace)' }, + }, + }, + } +} + +/** + * Build outputs for database event triggers. + */ +export function buildDatabaseEventOutputs(): Record { + return { + ...buildBaseOutputs(), + authors: { + type: 'array', + description: 'Array of users who triggered the event', + }, + entity: buildEntityOutputs(), + data: { + parent: { + id: { type: 'string', description: 'Parent page or workspace ID' }, + parent_type: { type: 'string', description: 'Parent type (page, workspace)' }, + }, + }, + } +} + +/** + * Build outputs for comment event triggers. + */ +export function buildCommentEventOutputs(): Record { + return { + ...buildBaseOutputs(), + authors: { + type: 'array', + description: 'Array of users who triggered the event', + }, + entity: { + id: { type: 'string', description: 'Comment ID' }, + entity_type: { type: 'string', description: 'Entity type (comment)' }, + }, + data: { + parent: { + id: { type: 'string', description: 'Parent page ID' }, + parent_type: { type: 'string', description: 'Parent type (page)' }, + }, + }, + } +} + +/** + * Build outputs for the generic webhook trigger (all events). + */ +export function buildGenericWebhookOutputs(): Record { + return { + ...buildBaseOutputs(), + authors: { + type: 'array', + description: 'Array of users who triggered the event', + }, + entity: buildEntityOutputs(), + data: { + type: 'json', + description: 'Event-specific data including parent information', + }, + } +} + +/** + * Maps trigger IDs to the Notion event type strings they accept. + */ +const TRIGGER_EVENT_MAP: Record = { + notion_page_created: ['page.created'], + notion_page_properties_updated: ['page.properties_updated'], + notion_page_content_updated: ['page.content_updated'], + notion_page_deleted: ['page.deleted'], + notion_database_created: ['database.created'], + notion_database_schema_updated: ['database.schema_updated'], + notion_database_deleted: ['database.deleted'], + notion_comment_created: ['comment.created'], +} + +/** + * Checks if a Notion webhook payload matches a trigger. + */ +export function isNotionPayloadMatch(triggerId: string, body: Record): boolean { + if (triggerId === 'notion_webhook') { + return true + } + + const eventType = body.type as string | undefined + if (!eventType) { + return false + } + + const acceptedEvents = TRIGGER_EVENT_MAP[triggerId] + return acceptedEvents ? acceptedEvents.includes(eventType) : false +} diff --git a/apps/sim/triggers/notion/webhook.ts b/apps/sim/triggers/notion/webhook.ts new file mode 100644 index 00000000000..db3a824df0d --- /dev/null +++ b/apps/sim/triggers/notion/webhook.ts @@ -0,0 +1,38 @@ +import { NotionIcon } from '@/components/icons' +import { buildTriggerSubBlocks } from '@/triggers' +import { + buildGenericWebhookOutputs, + buildNotionExtraFields, + notionSetupInstructions, + notionTriggerOptions, +} from '@/triggers/notion/utils' +import type { TriggerConfig } from '@/triggers/types' + +/** + * Notion Generic Webhook Trigger (All Events) + */ +export const notionWebhookTrigger: TriggerConfig = { + id: 'notion_webhook', + name: 'Notion Webhook (All Events)', + provider: 'notion', + description: 'Trigger workflow on any Notion webhook event', + version: '1.0.0', + icon: NotionIcon, + + subBlocks: buildTriggerSubBlocks({ + triggerId: 'notion_webhook', + triggerOptions: notionTriggerOptions, + setupInstructions: notionSetupInstructions('all desired'), + extraFields: buildNotionExtraFields('notion_webhook'), + }), + + outputs: buildGenericWebhookOutputs(), + + webhook: { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-Notion-Signature': 'sha256=...', + }, + }, +} diff --git a/apps/sim/triggers/registry.ts b/apps/sim/triggers/registry.ts index ffd46f277e7..abf1cd97069 100644 --- a/apps/sim/triggers/registry.ts +++ b/apps/sim/triggers/registry.ts @@ -189,6 +189,17 @@ import { microsoftTeamsChatSubscriptionTrigger, microsoftTeamsWebhookTrigger, } from '@/triggers/microsoftteams' +import { + notionCommentCreatedTrigger, + notionDatabaseCreatedTrigger, + notionDatabaseDeletedTrigger, + notionDatabaseSchemaUpdatedTrigger, + notionPageContentUpdatedTrigger, + notionPageCreatedTrigger, + notionPageDeletedTrigger, + notionPagePropertiesUpdatedTrigger, + notionWebhookTrigger, +} from '@/triggers/notion' import { outlookPollingTrigger } from '@/triggers/outlook' import { resendEmailBouncedTrigger, @@ -353,6 +364,15 @@ export const TRIGGER_REGISTRY: TriggerRegistry = { linear_customer_request_updated: linearCustomerRequestUpdatedTrigger, microsoftteams_webhook: microsoftTeamsWebhookTrigger, microsoftteams_chat_subscription: microsoftTeamsChatSubscriptionTrigger, + notion_page_created: notionPageCreatedTrigger, + notion_page_properties_updated: notionPagePropertiesUpdatedTrigger, + notion_page_content_updated: notionPageContentUpdatedTrigger, + notion_page_deleted: notionPageDeletedTrigger, + notion_database_created: notionDatabaseCreatedTrigger, + notion_database_schema_updated: notionDatabaseSchemaUpdatedTrigger, + notion_database_deleted: notionDatabaseDeletedTrigger, + notion_comment_created: notionCommentCreatedTrigger, + notion_webhook: notionWebhookTrigger, outlook_poller: outlookPollingTrigger, resend_email_sent: resendEmailSentTrigger, resend_email_delivered: resendEmailDeliveredTrigger, From c18f02384ae095fe6e0a1d4cb04bdcbea74dff6c Mon Sep 17 00:00:00 2001 From: Waleed Date: Mon, 6 Apr 2026 12:26:28 -0700 Subject: [PATCH 10/32] feat(analytics): add Google Tag Manager and Google Analytics for hosted environments (#3993) --- apps/sim/app/layout.tsx | 50 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 49 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/layout.tsx b/apps/sim/app/layout.tsx index c7b35d1c1fa..604639f2b7f 100644 --- a/apps/sim/app/layout.tsx +++ b/apps/sim/app/layout.tsx @@ -6,7 +6,7 @@ import { PostHogProvider } from '@/app/_shell/providers/posthog-provider' import { generateBrandedMetadata, generateThemeCSS } from '@/ee/whitelabeling' import '@/app/_styles/globals.css' import { OneDollarStats } from '@/components/analytics/onedollarstats' -import { isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags' +import { isHosted, isReactGrabEnabled, isReactScanEnabled } from '@/lib/core/config/feature-flags' import { HydrationErrorHandler } from '@/app/_shell/hydration-error-handler' import { QueryProvider } from '@/app/_shell/providers/query-provider' import { SessionProvider } from '@/app/_shell/providers/session-provider' @@ -25,6 +25,9 @@ export const viewport: Viewport = { export const metadata: Metadata = generateBrandedMetadata() +const GTM_ID = 'GTM-T7PHSRX5' as const +const GA_ID = 'G-DR7YBE70VS' as const + export default function RootLayout({ children }: { children: React.ReactNode }) { const themeCSS = generateThemeCSS() @@ -208,9 +211,54 @@ export default function RootLayout({ children }: { children: React.ReactNode })