diff --git a/.agents/skills/add-trigger/SKILL.md b/.agents/skills/add-trigger/SKILL.md
index fbf27ef625d..fd6df46e505 100644
--- a/.agents/skills/add-trigger/SKILL.md
+++ b/.agents/skills/add-trigger/SKILL.md
@@ -3,63 +3,57 @@ name: add-trigger
description: Create or update Sim webhook triggers using the generic trigger builder, service-specific setup instructions, outputs, and registry wiring. Use when working in `apps/sim/triggers/{service}/` or adding webhook support to an integration.
---
-# Add Trigger Skill
+# Add Trigger
You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks.
## Your Task
-When the user asks you to create triggers for a service:
1. Research what webhook events the service supports
2. Create the trigger files using the generic builder
-3. Register triggers and connect them to the block
+3. Create a provider handler if custom auth, formatting, or subscriptions are needed
+4. Register triggers and connect them to the block
## Directory Structure
```
apps/sim/triggers/{service}/
├── index.ts # Barrel exports
-├── utils.ts # Service-specific helpers (trigger options, setup instructions, extra fields)
+├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs)
├── {event_a}.ts # Primary trigger (includes dropdown)
├── {event_b}.ts # Secondary trigger (no dropdown)
-├── {event_c}.ts # Secondary trigger (no dropdown)
└── webhook.ts # Generic webhook trigger (optional, for "all events")
+
+apps/sim/lib/webhooks/
+├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl)
+├── providers/
+│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions)
+│ ├── types.ts # WebhookProviderHandler interface
+│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes)
+│ └── registry.ts # Handler map + default handler
```
-## Step 1: Create utils.ts
+## Step 1: Create `utils.ts`
-This file contains service-specific helpers used by all triggers.
+This file contains all service-specific helpers used by triggers.
```typescript
import type { SubBlockConfig } from '@/blocks/types'
import type { TriggerOutput } from '@/triggers/types'
-/**
- * Dropdown options for the trigger type selector.
- * These appear in the primary trigger's dropdown.
- */
export const {service}TriggerOptions = [
{ label: 'Event A', id: '{service}_event_a' },
{ label: 'Event B', id: '{service}_event_b' },
- { label: 'Event C', id: '{service}_event_c' },
- { label: 'Generic Webhook (All Events)', id: '{service}_webhook' },
]
-/**
- * Generates HTML setup instructions for the trigger.
- * Displayed to users to help them configure webhooks in the external service.
- */
export function {service}SetupInstructions(eventType: string): string {
const instructions = [
'Copy the Webhook URL above',
'Go to {Service} Settings > Webhooks',
- 'Click Add Webhook',
- 'Paste the webhook URL',
`Select the ${eventType} event type`,
- 'Save the webhook configuration',
+ 'Paste the webhook URL and save',
'Click "Save" above to activate your trigger',
]
-
return instructions
.map((instruction, index) =>
`
${index + 1}. ${instruction}
`
@@ -67,10 +61,6 @@ export function {service}SetupInstructions(eventType: string): string {
.join('')
}
-/**
- * Service-specific extra fields to add to triggers.
- * These are inserted between webhookUrl and triggerSave.
- */
export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
return [
{
@@ -78,53 +68,34 @@ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
title: 'Project ID (Optional)',
type: 'short-input',
placeholder: 'Leave empty for all projects',
- description: 'Optionally filter to a specific project',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
]
}
-/**
- * Build outputs for this trigger type.
- * Outputs define what data is available to downstream blocks.
- */
export function build{Service}Outputs(): Record {
return {
- eventType: { type: 'string', description: 'The type of event that triggered this workflow' },
+ eventType: { type: 'string', description: 'The type of event' },
resourceId: { type: 'string', description: 'ID of the affected resource' },
- timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' },
- // Nested outputs for complex data
resource: {
id: { type: 'string', description: 'Resource ID' },
name: { type: 'string', description: 'Resource name' },
- status: { type: 'string', description: 'Current status' },
},
- webhook: { type: 'json', description: 'Full webhook payload' },
}
}
```
-## Step 2: Create the Primary Trigger
+## Step 2: Create Trigger Files
-The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types.
+**Primary trigger** — MUST include `includeDropdown: true`:
```typescript
import { {Service}Icon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
-import {
- build{Service}ExtraFields,
- build{Service}Outputs,
- {service}SetupInstructions,
- {service}TriggerOptions,
-} from '@/triggers/{service}/utils'
+import { build{Service}ExtraFields, build{Service}Outputs, {service}SetupInstructions, {service}TriggerOptions } from '@/triggers/{service}/utils'
import type { TriggerConfig } from '@/triggers/types'
-/**
- * {Service} Event A Trigger
- *
- * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type.
- */
export const {service}EventATrigger: TriggerConfig = {
id: '{service}_event_a',
name: '{Service} Event A',
@@ -132,496 +103,222 @@ export const {service}EventATrigger: TriggerConfig = {
description: 'Trigger workflow when Event A occurs',
version: '1.0.0',
icon: {Service}Icon,
-
subBlocks: buildTriggerSubBlocks({
triggerId: '{service}_event_a',
triggerOptions: {service}TriggerOptions,
- includeDropdown: true, // PRIMARY TRIGGER - includes dropdown
+ includeDropdown: true,
setupInstructions: {service}SetupInstructions('Event A'),
extraFields: build{Service}ExtraFields('{service}_event_a'),
}),
-
outputs: build{Service}Outputs(),
-
- webhook: {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- },
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
}
```
-## Step 3: Create Secondary Triggers
-
-Secondary triggers do NOT include the dropdown (it's already in the primary trigger).
+**Secondary triggers** — NO `includeDropdown` (it's already in the primary):
```typescript
-import { {Service}Icon } from '@/components/icons'
-import { buildTriggerSubBlocks } from '@/triggers'
-import {
- build{Service}ExtraFields,
- build{Service}Outputs,
- {service}SetupInstructions,
- {service}TriggerOptions,
-} from '@/triggers/{service}/utils'
-import type { TriggerConfig } from '@/triggers/types'
-
-/**
- * {Service} Event B Trigger
- */
export const {service}EventBTrigger: TriggerConfig = {
- id: '{service}_event_b',
- name: '{Service} Event B',
- provider: '{service}',
- description: 'Trigger workflow when Event B occurs',
- version: '1.0.0',
- icon: {Service}Icon,
-
- subBlocks: buildTriggerSubBlocks({
- triggerId: '{service}_event_b',
- triggerOptions: {service}TriggerOptions,
- // NO includeDropdown - secondary trigger
- setupInstructions: {service}SetupInstructions('Event B'),
- extraFields: build{Service}ExtraFields('{service}_event_b'),
- }),
-
- outputs: build{Service}Outputs(),
-
- webhook: {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- },
+ // Same as above but: id: '{service}_event_b', no includeDropdown
}
```
-## Step 4: Create index.ts Barrel Export
+## Step 3: Register and Wire
+
+### `apps/sim/triggers/{service}/index.ts`
```typescript
export { {service}EventATrigger } from './event_a'
export { {service}EventBTrigger } from './event_b'
-export { {service}EventCTrigger } from './event_c'
-export { {service}WebhookTrigger } from './webhook'
```
-## Step 5: Register Triggers
-
-### Trigger Registry (`apps/sim/triggers/registry.ts`)
+### `apps/sim/triggers/registry.ts`
```typescript
-// Add import
-import {
- {service}EventATrigger,
- {service}EventBTrigger,
- {service}EventCTrigger,
- {service}WebhookTrigger,
-} from '@/triggers/{service}'
-
-// Add to TRIGGER_REGISTRY
+import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}'
+
export const TRIGGER_REGISTRY: TriggerRegistry = {
- // ... existing triggers ...
+ // ... existing ...
{service}_event_a: {service}EventATrigger,
{service}_event_b: {service}EventBTrigger,
- {service}_event_c: {service}EventCTrigger,
- {service}_webhook: {service}WebhookTrigger,
}
```
-## Step 6: Connect Triggers to Block
-
-In the block file (`apps/sim/blocks/blocks/{service}.ts`):
+### Block file (`apps/sim/blocks/blocks/{service}.ts`)
```typescript
-import { {Service}Icon } from '@/components/icons'
import { getTrigger } from '@/triggers'
-import type { BlockConfig } from '@/blocks/types'
export const {Service}Block: BlockConfig = {
- type: '{service}',
- name: '{Service}',
- // ... other config ...
-
- // Enable triggers and list available trigger IDs
+ // ...
triggers: {
enabled: true,
- available: [
- '{service}_event_a',
- '{service}_event_b',
- '{service}_event_c',
- '{service}_webhook',
- ],
+ available: ['{service}_event_a', '{service}_event_b'],
},
-
subBlocks: [
- // Regular tool subBlocks first
- { id: 'operation', /* ... */ },
- { id: 'credential', /* ... */ },
- // ... other tool fields ...
-
- // Then spread ALL trigger subBlocks
+ // Regular tool subBlocks first...
...getTrigger('{service}_event_a').subBlocks,
...getTrigger('{service}_event_b').subBlocks,
- ...getTrigger('{service}_event_c').subBlocks,
- ...getTrigger('{service}_webhook').subBlocks,
],
-
- // ... tools config ...
}
```
-## Automatic Webhook Registration (Preferred)
-
-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.
+## Provider Handler
-### When to Use Automatic Registration
+All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`.
-Check the service's API documentation for endpoints like:
-- `POST /webhooks` or `POST /hooks` - Create webhook
-- `DELETE /webhooks/{id}` - Delete webhook
+### When to Create a Handler
-Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc.
+| Behavior | Method | Examples |
+|---|---|---|
+| HMAC signature auth | `verifyAuth` via `createHmacVerifier` | Ashby, Jira, Linear, Typeform |
+| Custom token auth | `verifyAuth` via `verifyTokenAuth` | Generic, Google Forms |
+| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot |
+| Idempotency dedup | `extractIdempotencyId` | Slack, Stripe, Linear, Jira |
+| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby |
+| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable |
+| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable |
+| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams |
+| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams |
-### Implementation Steps
+If none apply, you don't need a handler. The default handler provides bearer token auth.
-#### 1. Add API Key to Extra Fields
-
-Update your `build{Service}ExtraFields` function to include an API key field:
+### Example Handler
```typescript
-export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
- return [
- {
- id: 'apiKey',
- title: 'API Key',
- type: 'short-input',
- placeholder: 'Enter your {Service} API key',
- description: 'Required to create the webhook in {Service}.',
- password: true,
- required: true,
- mode: 'trigger',
- condition: { field: 'selectedTriggerId', value: triggerId },
- },
- // Other optional fields (e.g., campaign filter, project filter)
- {
- id: 'projectId',
- title: 'Project ID (Optional)',
- type: 'short-input',
- placeholder: 'Leave empty for all projects',
- mode: 'trigger',
- condition: { field: 'selectedTriggerId', value: triggerId },
- },
- ]
+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:{Service}')
+
+function validate{Service}Signature(secret: string, signature: string, body: string): boolean {
+ if (!secret || !signature || !body) return false
+ const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
+ return safeCompare(computed, signature)
}
-```
-
-#### 2. Update Setup Instructions for Automatic Creation
-
-Change instructions to indicate automatic webhook creation:
-
-```typescript
-export function {service}SetupInstructions(eventType: string): string {
- const instructions = [
- 'Enter your {Service} API Key above.',
- 'You can find your API key in {Service} at Settings > API.',
- `Click "Save Configuration" to automatically create the webhook in {Service} for ${eventType} events.`,
- 'The webhook will be automatically deleted when you remove this trigger.',
- ]
- return instructions
- .map((instruction, index) =>
- `${index + 1}. ${instruction}
`
- )
- .join('')
-}
-```
-
-#### 3. Add Webhook Creation to API Route
-
-In `apps/sim/app/api/webhooks/route.ts`, add provider-specific logic after the database save:
-
-```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,
- }
- 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 || {}
-
- if (!apiKey) {
- throw new Error('{Service} API Key is required.')
- }
-
- // 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 eventType = eventTypeMap[triggerId]
- const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
-
- const requestBody: Record = {
- url: notificationUrl,
- }
-
- if (eventType) {
- requestBody.eventType = eventType
- }
+export const {service}Handler: WebhookProviderHandler = {
+ verifyAuth: createHmacVerifier({
+ configKey: 'webhookSecret',
+ headerName: 'X-{Service}-Signature',
+ validateFn: validate{Service}Signature,
+ providerLabel: '{Service}',
+ }),
- if (projectId) {
- requestBody.projectId = projectId
+ async matchEvent({ body, requestId, providerConfig }: EventMatchContext) {
+ const triggerId = providerConfig.triggerId as string | undefined
+ if (triggerId && triggerId !== '{service}_webhook') {
+ const { is{Service}EventMatch } = await import('@/triggers/{service}/utils')
+ if (!is{Service}EventMatch(triggerId, body as Record)) return false
}
+ return true
+ },
- const response = await fetch('https://api.{service}.com/webhooks', {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${apiKey}`,
- 'Content-Type': 'application/json',
+ async formatInput({ body }: FormatInputContext): Promise {
+ const b = body as Record
+ return {
+ input: {
+ eventType: b.type,
+ resourceId: (b.data as Record)?.id || '',
+ resource: b.data,
},
- body: JSON.stringify(requestBody),
- })
-
- const responseBody = await response.json()
-
- if (!response.ok) {
- const errorMessage = responseBody.message || '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)
}
+ },
- return { id: responseBody.id }
- } catch (error: any) {
- logger.error(`Exception during {Service} webhook creation`, { error: error.message })
- throw error
- }
+ extractIdempotencyId(body: unknown) {
+ const obj = body as Record
+ return obj.id && obj.type ? `${obj.type}:${obj.id}` : null
+ },
}
```
-#### 4. Add Webhook Deletion to Provider Subscriptions
-
-In `apps/sim/lib/webhooks/provider-subscriptions.ts`:
+### Register the Handler
-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
-
- if (!apiKey || !externalId) {
- {service}Logger.warn(`[${requestId}] Missing apiKey or externalId, skipping cleanup`)
- return
- }
-
- const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, {
- method: 'DELETE',
- headers: {
- Authorization: `Bearer ${apiKey}`,
- },
- })
-
- 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)
- }
-}
-```
+In `apps/sim/lib/webhooks/providers/registry.ts`:
-3. Add to `cleanupExternalWebhook`:
```typescript
-export async function cleanupExternalWebhook(...): Promise {
- // ... existing providers ...
- } else if (webhook.provider === '{service}') {
- await delete{Service}Webhook(webhook, requestId)
- }
-}
-```
-
-### 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
+import { {service}Handler } from '@/lib/webhooks/providers/{service}'
-## The buildTriggerSubBlocks Helper
-
-This is the generic helper from `@/triggers` that creates consistent trigger subBlocks.
-
-### Function Signature
-
-```typescript
-interface BuildTriggerSubBlocksOptions {
- triggerId: string // e.g., 'service_event_a'
- triggerOptions: Array<{ label: string; id: string }> // Dropdown options
- includeDropdown?: boolean // true only for primary trigger
- setupInstructions: string // HTML instructions
- extraFields?: SubBlockConfig[] // Service-specific fields
- webhookPlaceholder?: string // Custom placeholder text
+const PROVIDER_HANDLERS: Record = {
+ // ... existing (alphabetical) ...
+ {service}: {service}Handler,
}
-
-function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[]
```
-### What It Creates
+## Output Alignment (Critical)
-The helper creates this structure:
-1. **Dropdown** (only if `includeDropdown: true`) - Trigger type selector
-2. **Webhook URL** - Read-only field with copy button
-3. **Extra Fields** - Your service-specific fields (filters, options, etc.)
-4. **Save Button** - Activates the trigger
-5. **Instructions** - Setup guide for users
+There are two sources of truth that **MUST be aligned**:
-All fields automatically have:
-- `mode: 'trigger'` - Only shown in trigger mode
-- `condition: { field: 'selectedTriggerId', value: triggerId }` - Only shown when this trigger is selected
+1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown)
+2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data
-## Trigger Outputs & Webhook Input Formatting
+If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover.
-### Important: Two Sources of Truth
+**Rules for `formatInput`:**
+- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly
+- Return `{ input: ..., skip: { message: '...' } }` to skip execution
+- No wrapper objects or duplication
+- Use `null` for missing optional data
-There are two related but separate concerns:
+## Automatic Webhook Registration
-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`.
+If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**.
-**These MUST be aligned.** The fields returned by `formatWebhookInput` 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
+```typescript
+import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
+import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types'
-- **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.
+export const {service}Handler: WebhookProviderHandler = {
+ async createSubscription(ctx: SubscriptionContext): Promise {
+ const config = getProviderConfig(ctx.webhook)
+ const apiKey = config.apiKey as string
+ if (!apiKey) throw new Error('{Service} API Key is required.')
-### Adding a Handler
+ const res = await fetch('https://api.{service}.com/webhooks', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }),
+ })
-In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block:
+ if (!res.ok) throw new Error(`{Service} error: ${res.status}`)
+ const { id } = (await res.json()) as { id: string }
+ return { providerConfigUpdates: { externalId: id } }
+ },
-```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,
- }
+ async deleteSubscription(ctx: DeleteSubscriptionContext): Promise {
+ const config = getProviderConfig(ctx.webhook)
+ const { apiKey, externalId } = config as { apiKey?: string; externalId?: string }
+ if (!apiKey || !externalId) return
+ await fetch(`https://api.{service}.com/webhooks/${externalId}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${apiKey}` },
+ }).catch(() => {})
+ },
}
```
-**Key rules:**
-- Return fields that match your trigger `outputs` definition exactly
-- 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
+**Key points:**
+- Throw from `createSubscription` — orchestration rolls back the DB webhook
+- Never throw from `deleteSubscription` — log non-fatally
+- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig`
+- Add `apiKey` field to `build{Service}ExtraFields` with `password: true`
-### Verify Alignment
-
-Run the alignment checker:
-```bash
-bunx scripts/check-trigger-alignment.ts {service}
-```
-
-## Trigger Outputs
+## Trigger Outputs Schema
Trigger outputs use the same schema as block outputs (NOT tool outputs).
-**Supported:**
-- `type` and `description` for simple fields
-- Nested object structure for complex data
-
-**NOT Supported:**
-- `optional: true` (tool outputs only)
-- `items` property (tool outputs only)
+**Supported:** `type` + `description` for leaf fields, nested objects for complex data.
+**NOT supported:** `optional: true`, `items` (those are tool-output-only features).
```typescript
export function buildOutputs(): Record {
return {
- // Simple fields
eventType: { type: 'string', description: 'Event type' },
timestamp: { type: 'string', description: 'When it occurred' },
-
- // Complex data - use type: 'json'
payload: { type: 'json', description: 'Full event payload' },
-
- // Nested structure
resource: {
id: { type: 'string', description: 'Resource ID' },
name: { type: 'string', description: 'Resource name' },
@@ -630,79 +327,32 @@ export function buildOutputs(): Record {
}
```
-## Generic Webhook Trigger Pattern
-
-For services with many event types, create a generic webhook that accepts all events:
-
-```typescript
-export const {service}WebhookTrigger: TriggerConfig = {
- id: '{service}_webhook',
- name: '{Service} Webhook (All Events)',
- // ...
-
- subBlocks: buildTriggerSubBlocks({
- triggerId: '{service}_webhook',
- triggerOptions: {service}TriggerOptions,
- setupInstructions: {service}SetupInstructions('All Events'),
- extraFields: [
- // Event type filter (optional)
- {
- id: 'eventTypes',
- title: 'Event Types',
- type: 'dropdown',
- multiSelect: true,
- options: [
- { label: 'Event A', id: 'event_a' },
- { label: 'Event B', id: 'event_b' },
- ],
- placeholder: 'Leave empty for all events',
- mode: 'trigger',
- condition: { field: 'selectedTriggerId', value: '{service}_webhook' },
- },
- // Plus any other service-specific fields
- ...build{Service}ExtraFields('{service}_webhook'),
- ],
- }),
-}
-```
-
-## Checklist Before Finishing
-
-### Utils
-- [ ] Created `{service}TriggerOptions` array with all trigger IDs
-- [ ] Created `{service}SetupInstructions` function with clear steps
-- [ ] Created `build{Service}ExtraFields` for service-specific fields
-- [ ] Created output builders for each trigger type
+## Checklist
-### Triggers
-- [ ] Primary trigger has `includeDropdown: true`
-- [ ] Secondary triggers do NOT have `includeDropdown`
+### Trigger Definition
+- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders
+- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT
- [ ] All triggers use `buildTriggerSubBlocks` helper
-- [ ] All triggers have proper outputs defined
- [ ] Created `index.ts` barrel export
### Registration
-- [ ] All triggers imported in `triggers/registry.ts`
-- [ ] All triggers added to `TRIGGER_REGISTRY`
-- [ ] Block has `triggers.enabled: true`
-- [ ] Block has all trigger IDs in `triggers.available`
+- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY`
+- [ ] Block has `triggers.enabled: true` and lists all trigger IDs in `triggers.available`
- [ ] Block spreads all trigger subBlocks: `...getTrigger('id').subBlocks`
-### 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
+### Provider Handler (if needed)
+- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts`
+- [ ] Registered in `providers/registry.ts` (alphabetical)
+- [ ] Signature validator is a private function inside the handler file
+- [ ] `formatInput` output keys match trigger `outputs` exactly
+- [ ] Event matching uses dynamic `await import()` for trigger utils
-### 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
+### Auto Registration (if supported)
+- [ ] `createSubscription` and `deleteSubscription` on the handler
+- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
+- [ ] API key field uses `password: true`
### Testing
-- [ ] Run `bun run type-check` to verify no TypeScript errors
-- [ ] Restart dev server to pick up new triggers
-- [ ] Test trigger UI shows correctly in the block
-- [ ] Test automatic webhook creation works (if applicable)
+- [ ] `bun run type-check` passes
+- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys
+- [ ] Trigger UI shows correctly in the block
diff --git a/.agents/skills/validate-trigger/SKILL.md b/.agents/skills/validate-trigger/SKILL.md
new file mode 100644
index 00000000000..ff1eb775b44
--- /dev/null
+++ b/.agents/skills/validate-trigger/SKILL.md
@@ -0,0 +1,212 @@
+---
+name: validate-trigger
+description: Audit an existing Sim webhook trigger against the service's webhook API docs and repository conventions, then report and fix issues across trigger definitions, provider handler, output alignment, registration, and security. Use when validating or repairing a trigger under `apps/sim/triggers/{service}/` or `apps/sim/lib/webhooks/providers/{service}.ts`.
+---
+
+# Validate Trigger
+
+You are an expert auditor for Sim webhook triggers. Your job is to validate that an existing trigger implementation is correct, complete, secure, and aligned across all layers.
+
+## Your Task
+
+1. Read the service's webhook/API documentation (via WebFetch)
+2. Read every trigger file, provider handler, and registry entry
+3. Cross-reference against the API docs and Sim conventions
+4. Report all issues grouped by severity (critical, warning, suggestion)
+5. Fix all issues after reporting them
+
+## Step 1: Gather All Files
+
+Read **every** file for the trigger — do not skip any:
+
+```
+apps/sim/triggers/{service}/ # All trigger files, utils.ts, index.ts
+apps/sim/lib/webhooks/providers/{service}.ts # Provider handler (if exists)
+apps/sim/lib/webhooks/providers/registry.ts # Handler registry
+apps/sim/triggers/registry.ts # Trigger registry
+apps/sim/blocks/blocks/{service}.ts # Block definition (trigger wiring)
+```
+
+Also read for reference:
+```
+apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface
+apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.)
+apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers
+apps/sim/lib/webhooks/processor.ts # Central webhook processor
+```
+
+## Step 2: Pull API Documentation
+
+Fetch the service's official webhook documentation. This is the **source of truth** for:
+- Webhook event types and payload shapes
+- Signature/auth verification method (HMAC algorithm, header names, secret format)
+- Challenge/verification handshake requirements
+- Webhook subscription API (create/delete endpoints, if applicable)
+- Retry behavior and delivery guarantees
+
+## Step 3: Validate Trigger Definitions
+
+### utils.ts
+- [ ] `{service}TriggerOptions` lists all trigger IDs accurately
+- [ ] `{service}SetupInstructions` provides clear, correct steps for the service
+- [ ] `build{Service}ExtraFields` includes relevant filter/config fields with correct `condition`
+- [ ] Output builders expose all meaningful fields from the webhook payload
+- [ ] Output builders do NOT use `optional: true` or `items` (tool-output-only features)
+- [ ] Nested output objects correctly model the payload structure
+
+### Trigger Files
+- [ ] Exactly one primary trigger has `includeDropdown: true`
+- [ ] All secondary triggers do NOT have `includeDropdown`
+- [ ] All triggers use `buildTriggerSubBlocks` helper (not hand-rolled subBlocks)
+- [ ] Every trigger's `id` matches the convention `{service}_{event_name}`
+- [ ] Every trigger's `provider` matches the service name used in the handler registry
+- [ ] `index.ts` barrel exports all triggers
+
+### Trigger ↔ Provider Alignment (CRITICAL)
+- [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions`
+- [ ] Event matching logic in the provider correctly maps trigger IDs to service event types
+- [ ] Event matching logic in `is{Service}EventMatch` (if exists) correctly identifies events per the API docs
+
+## Step 4: Validate Provider Handler
+
+### Auth Verification
+- [ ] `verifyAuth` correctly validates webhook signatures per the service's documentation
+- [ ] HMAC algorithm matches (SHA-1, SHA-256, SHA-512)
+- [ ] Signature header name matches the API docs exactly
+- [ ] Signature format is handled (raw hex, `sha256=` prefix, base64, etc.)
+- [ ] Uses `safeCompare` for timing-safe comparison (no `===`)
+- [ ] If `webhookSecret` is required, handler rejects when it's missing (fail-closed)
+- [ ] Signature is computed over raw body (not parsed JSON)
+
+### Event Matching
+- [ ] `matchEvent` returns `boolean` (not `NextResponse` or other values)
+- [ ] Challenge/verification events are excluded from matching (e.g., `endpoint.url_validation`)
+- [ ] When `triggerId` is a generic webhook ID, all events pass through
+- [ ] When `triggerId` is specific, only matching events pass
+- [ ] Event matching logic uses dynamic `await import()` for trigger utils (avoids circular deps)
+
+### formatInput (CRITICAL)
+- [ ] Every key in the `formatInput` return matches a key in the trigger `outputs` schema
+- [ ] Every key in the trigger `outputs` schema is populated by `formatInput`
+- [ ] No extra undeclared keys that users can't discover in the UI
+- [ ] No wrapper objects (`webhook: { ... }`, `{service}: { ... }`)
+- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`)
+- [ ] `null` is used for missing optional fields (not empty strings or empty objects)
+- [ ] Returns `{ input: { ... } }` — not a bare object
+
+### Idempotency
+- [ ] `extractIdempotencyId` returns a stable, unique key per delivery
+- [ ] Uses provider-specific delivery IDs when available (e.g., `X-Request-Id`, `Linear-Delivery`, `svix-id`)
+- [ ] Falls back to content-based ID (e.g., `${type}:${id}`) when no delivery header exists
+- [ ] Does NOT include timestamps in the idempotency key (would break dedup on retries)
+
+### Challenge Handling (if applicable)
+- [ ] `handleChallenge` correctly implements the service's URL verification handshake
+- [ ] Returns the expected response format per the API docs
+- [ ] Env-backed secrets are resolved via `resolveEnvVarsInObject` if needed
+
+## Step 5: Validate Automatic Subscription Lifecycle
+
+If the service supports programmatic webhook creation:
+
+### createSubscription
+- [ ] Calls the correct API endpoint to create a webhook
+- [ ] Sends the correct event types/filters
+- [ ] Passes the notification URL from `getNotificationUrl(ctx.webhook)`
+- [ ] Returns `{ providerConfigUpdates: { externalId } }` with the external webhook ID
+- [ ] Throws on failure (orchestration handles rollback)
+- [ ] Provides user-friendly error messages (401 → "Invalid API Key", etc.)
+
+### deleteSubscription
+- [ ] Calls the correct API endpoint to delete the webhook
+- [ ] Handles 404 gracefully (webhook already deleted)
+- [ ] Never throws — catches errors and logs non-fatally
+- [ ] Skips gracefully when `apiKey` or `externalId` is missing
+
+### Orchestration Isolation
+- [ ] NO provider-specific logic in `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
+- [ ] All subscription logic lives on the handler (`createSubscription`/`deleteSubscription`)
+
+## Step 6: Validate Registration and Block Wiring
+
+### Trigger Registry (`triggers/registry.ts`)
+- [ ] All triggers are imported and registered
+- [ ] Registry keys match trigger IDs exactly
+- [ ] No orphaned entries (triggers that don't exist)
+
+### Provider Handler Registry (`providers/registry.ts`)
+- [ ] Handler is imported and registered (if handler exists)
+- [ ] Registry key matches the `provider` field on the trigger configs
+- [ ] Entries are in alphabetical order
+
+### Block Wiring (`blocks/blocks/{service}.ts`)
+- [ ] Block has `triggers.enabled: true`
+- [ ] `triggers.available` lists all trigger IDs
+- [ ] All trigger subBlocks are spread into `subBlocks`: `...getTrigger('id').subBlocks`
+- [ ] No trigger IDs in `triggers.available` that aren't in the registry
+- [ ] No trigger subBlocks spread that aren't in `triggers.available`
+
+## Step 7: Validate Security
+
+- [ ] Webhook secrets are never logged (not even at debug level)
+- [ ] Auth verification runs before any event processing
+- [ ] No secret comparison uses `===` (must use `safeCompare` or `crypto.timingSafeEqual`)
+- [ ] Timestamp/replay protection is reasonable (not too tight for retries, not too loose for security)
+- [ ] Raw body is used for signature verification (not re-serialized JSON)
+
+## Step 8: Report and Fix
+
+### Report Format
+
+Group findings by severity:
+
+**Critical** (runtime errors, security issues, or data loss):
+- Wrong HMAC algorithm or header name
+- `formatInput` keys don't match trigger `outputs`
+- Missing `verifyAuth` when the service sends signed webhooks
+- `matchEvent` returns non-boolean values
+- Provider-specific logic leaking into shared orchestration files
+- Trigger IDs mismatch between trigger files, registry, and block
+- `createSubscription` calling wrong API endpoint
+- Auth comparison using `===` instead of `safeCompare`
+
+**Warning** (convention violations or usability issues):
+- Missing `extractIdempotencyId` when the service provides delivery IDs
+- Timestamps in idempotency keys (breaks dedup on retries)
+- Missing challenge handling when the service requires URL verification
+- Output schema missing fields that `formatInput` returns (undiscoverable data)
+- Overly tight timestamp skew window that rejects legitimate retries
+- `matchEvent` not filtering challenge/verification events
+- Setup instructions missing important steps
+
+**Suggestion** (minor improvements):
+- More specific output field descriptions
+- Additional output fields that could be exposed
+- Better error messages in `createSubscription`
+- Logging improvements
+
+### Fix All Issues
+
+After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity.
+
+### Validation Output
+
+After fixing, confirm:
+1. `bun run type-check` passes
+2. Re-read all modified files to verify fixes are correct
+3. Provider handler tests pass (if they exist): `bun test {service}`
+
+## Checklist Summary
+
+- [ ] Read all trigger files, provider handler, types, registries, and block
+- [ ] Pulled and read official webhook/API documentation
+- [ ] Validated trigger definitions: options, instructions, extra fields, outputs
+- [ ] Validated primary/secondary trigger distinction (`includeDropdown`)
+- [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency
+- [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key
+- [ ] Validated subscription lifecycle: createSubscription, deleteSubscription, no shared-file edits
+- [ ] Validated registration: trigger registry, handler registry, block wiring
+- [ ] Validated security: safe comparison, no secret logging, replay protection
+- [ ] Reported all issues grouped by severity
+- [ ] Fixed all critical and warning issues
+- [ ] `bun run type-check` passes after fixes
diff --git a/.claude/commands/add-trigger.md b/.claude/commands/add-trigger.md
index d53e1bb609f..9cbeca68a3e 100644
--- a/.claude/commands/add-trigger.md
+++ b/.claude/commands/add-trigger.md
@@ -3,63 +3,57 @@ description: Create webhook triggers for a Sim integration using the generic tri
argument-hint:
---
-# Add Trigger Skill
+# Add Trigger
You are an expert at creating webhook triggers for Sim. You understand the trigger system, the generic `buildTriggerSubBlocks` helper, and how triggers connect to blocks.
## Your Task
-When the user asks you to create triggers for a service:
1. Research what webhook events the service supports
2. Create the trigger files using the generic builder
-3. Register triggers and connect them to the block
+3. Create a provider handler if custom auth, formatting, or subscriptions are needed
+4. Register triggers and connect them to the block
## Directory Structure
```
apps/sim/triggers/{service}/
├── index.ts # Barrel exports
-├── utils.ts # Service-specific helpers (trigger options, setup instructions, extra fields)
+├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs)
├── {event_a}.ts # Primary trigger (includes dropdown)
├── {event_b}.ts # Secondary trigger (no dropdown)
-├── {event_c}.ts # Secondary trigger (no dropdown)
└── webhook.ts # Generic webhook trigger (optional, for "all events")
+
+apps/sim/lib/webhooks/
+├── provider-subscription-utils.ts # Shared subscription helpers (getProviderConfig, getNotificationUrl)
+├── providers/
+│ ├── {service}.ts # Provider handler (auth, formatInput, matchEvent, subscriptions)
+│ ├── types.ts # WebhookProviderHandler interface
+│ ├── utils.ts # Shared helpers (createHmacVerifier, verifyTokenAuth, skipByEventTypes)
+│ └── registry.ts # Handler map + default handler
```
-## Step 1: Create utils.ts
+## Step 1: Create `utils.ts`
-This file contains service-specific helpers used by all triggers.
+This file contains all service-specific helpers used by triggers.
```typescript
import type { SubBlockConfig } from '@/blocks/types'
import type { TriggerOutput } from '@/triggers/types'
-/**
- * Dropdown options for the trigger type selector.
- * These appear in the primary trigger's dropdown.
- */
export const {service}TriggerOptions = [
{ label: 'Event A', id: '{service}_event_a' },
{ label: 'Event B', id: '{service}_event_b' },
- { label: 'Event C', id: '{service}_event_c' },
- { label: 'Generic Webhook (All Events)', id: '{service}_webhook' },
]
-/**
- * Generates HTML setup instructions for the trigger.
- * Displayed to users to help them configure webhooks in the external service.
- */
export function {service}SetupInstructions(eventType: string): string {
const instructions = [
'Copy the Webhook URL above',
'Go to {Service} Settings > Webhooks',
- 'Click Add Webhook',
- 'Paste the webhook URL',
`Select the ${eventType} event type`,
- 'Save the webhook configuration',
+ 'Paste the webhook URL and save',
'Click "Save" above to activate your trigger',
]
-
return instructions
.map((instruction, index) =>
`${index + 1}. ${instruction}
`
@@ -67,10 +61,6 @@ export function {service}SetupInstructions(eventType: string): string {
.join('')
}
-/**
- * Service-specific extra fields to add to triggers.
- * These are inserted between webhookUrl and triggerSave.
- */
export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
return [
{
@@ -78,53 +68,34 @@ export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
title: 'Project ID (Optional)',
type: 'short-input',
placeholder: 'Leave empty for all projects',
- description: 'Optionally filter to a specific project',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
]
}
-/**
- * Build outputs for this trigger type.
- * Outputs define what data is available to downstream blocks.
- */
export function build{Service}Outputs(): Record {
return {
- eventType: { type: 'string', description: 'The type of event that triggered this workflow' },
+ eventType: { type: 'string', description: 'The type of event' },
resourceId: { type: 'string', description: 'ID of the affected resource' },
- timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' },
- // Nested outputs for complex data
resource: {
id: { type: 'string', description: 'Resource ID' },
name: { type: 'string', description: 'Resource name' },
- status: { type: 'string', description: 'Current status' },
},
- webhook: { type: 'json', description: 'Full webhook payload' },
}
}
```
-## Step 2: Create the Primary Trigger
+## Step 2: Create Trigger Files
-The **primary trigger** is the first one listed. It MUST include `includeDropdown: true` so users can switch between trigger types.
+**Primary trigger** — MUST include `includeDropdown: true`:
```typescript
import { {Service}Icon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
-import {
- build{Service}ExtraFields,
- build{Service}Outputs,
- {service}SetupInstructions,
- {service}TriggerOptions,
-} from '@/triggers/{service}/utils'
+import { build{Service}ExtraFields, build{Service}Outputs, {service}SetupInstructions, {service}TriggerOptions } from '@/triggers/{service}/utils'
import type { TriggerConfig } from '@/triggers/types'
-/**
- * {Service} Event A Trigger
- *
- * This is the PRIMARY trigger - it includes the dropdown for selecting trigger type.
- */
export const {service}EventATrigger: TriggerConfig = {
id: '{service}_event_a',
name: '{Service} Event A',
@@ -132,476 +103,101 @@ export const {service}EventATrigger: TriggerConfig = {
description: 'Trigger workflow when Event A occurs',
version: '1.0.0',
icon: {Service}Icon,
-
subBlocks: buildTriggerSubBlocks({
triggerId: '{service}_event_a',
triggerOptions: {service}TriggerOptions,
- includeDropdown: true, // PRIMARY TRIGGER - includes dropdown
+ includeDropdown: true,
setupInstructions: {service}SetupInstructions('Event A'),
extraFields: build{Service}ExtraFields('{service}_event_a'),
}),
-
outputs: build{Service}Outputs(),
-
- webhook: {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- },
+ webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
}
```
-## Step 3: Create Secondary Triggers
-
-Secondary triggers do NOT include the dropdown (it's already in the primary trigger).
+**Secondary triggers** — NO `includeDropdown` (it's already in the primary):
```typescript
-import { {Service}Icon } from '@/components/icons'
-import { buildTriggerSubBlocks } from '@/triggers'
-import {
- build{Service}ExtraFields,
- build{Service}Outputs,
- {service}SetupInstructions,
- {service}TriggerOptions,
-} from '@/triggers/{service}/utils'
-import type { TriggerConfig } from '@/triggers/types'
-
-/**
- * {Service} Event B Trigger
- */
export const {service}EventBTrigger: TriggerConfig = {
- id: '{service}_event_b',
- name: '{Service} Event B',
- provider: '{service}',
- description: 'Trigger workflow when Event B occurs',
- version: '1.0.0',
- icon: {Service}Icon,
-
- subBlocks: buildTriggerSubBlocks({
- triggerId: '{service}_event_b',
- triggerOptions: {service}TriggerOptions,
- // NO includeDropdown - secondary trigger
- setupInstructions: {service}SetupInstructions('Event B'),
- extraFields: build{Service}ExtraFields('{service}_event_b'),
- }),
-
- outputs: build{Service}Outputs(),
-
- webhook: {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- },
+ // Same as above but: id: '{service}_event_b', no includeDropdown
}
```
-## Step 4: Create index.ts Barrel Export
+## Step 3: Register and Wire
+
+### `apps/sim/triggers/{service}/index.ts`
```typescript
export { {service}EventATrigger } from './event_a'
export { {service}EventBTrigger } from './event_b'
-export { {service}EventCTrigger } from './event_c'
-export { {service}WebhookTrigger } from './webhook'
```
-## Step 5: Register Triggers
-
-### Trigger Registry (`apps/sim/triggers/registry.ts`)
+### `apps/sim/triggers/registry.ts`
```typescript
-// Add import
-import {
- {service}EventATrigger,
- {service}EventBTrigger,
- {service}EventCTrigger,
- {service}WebhookTrigger,
-} from '@/triggers/{service}'
-
-// Add to TRIGGER_REGISTRY
+import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}'
+
export const TRIGGER_REGISTRY: TriggerRegistry = {
- // ... existing triggers ...
+ // ... existing ...
{service}_event_a: {service}EventATrigger,
{service}_event_b: {service}EventBTrigger,
- {service}_event_c: {service}EventCTrigger,
- {service}_webhook: {service}WebhookTrigger,
}
```
-## Step 6: Connect Triggers to Block
-
-In the block file (`apps/sim/blocks/blocks/{service}.ts`):
+### Block file (`apps/sim/blocks/blocks/{service}.ts`)
```typescript
-import { {Service}Icon } from '@/components/icons'
import { getTrigger } from '@/triggers'
-import type { BlockConfig } from '@/blocks/types'
export const {Service}Block: BlockConfig = {
- type: '{service}',
- name: '{Service}',
- // ... other config ...
-
- // Enable triggers and list available trigger IDs
+ // ...
triggers: {
enabled: true,
- available: [
- '{service}_event_a',
- '{service}_event_b',
- '{service}_event_c',
- '{service}_webhook',
- ],
+ available: ['{service}_event_a', '{service}_event_b'],
},
-
subBlocks: [
- // Regular tool subBlocks first
- { id: 'operation', /* ... */ },
- { id: 'credential', /* ... */ },
- // ... other tool fields ...
-
- // Then spread ALL trigger subBlocks
+ // Regular tool subBlocks first...
...getTrigger('{service}_event_a').subBlocks,
...getTrigger('{service}_event_b').subBlocks,
- ...getTrigger('{service}_event_c').subBlocks,
- ...getTrigger('{service}_webhook').subBlocks,
],
-
- // ... tools config ...
-}
-```
-
-## Automatic Webhook Registration (Preferred)
-
-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, Ashby, Attio, etc.
-
-### Implementation Steps
-
-#### 1. Add API Key to Extra Fields
-
-Update your `build{Service}ExtraFields` function to include an API key field:
-
-```typescript
-export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
- return [
- {
- id: 'apiKey',
- title: 'API Key',
- type: 'short-input',
- placeholder: 'Enter your {Service} API key',
- description: 'Required to create the webhook in {Service}.',
- password: true,
- required: true,
- mode: 'trigger',
- condition: { field: 'selectedTriggerId', value: triggerId },
- },
- // Other optional fields (e.g., campaign filter, project filter)
- {
- id: 'projectId',
- title: 'Project ID (Optional)',
- type: 'short-input',
- placeholder: 'Leave empty for all projects',
- mode: 'trigger',
- condition: { field: 'selectedTriggerId', value: triggerId },
- },
- ]
-}
-```
-
-#### 2. Update Setup Instructions for Automatic Creation
-
-Change instructions to indicate automatic webhook creation:
-
-```typescript
-export function {service}SetupInstructions(eventType: string): string {
- const instructions = [
- 'Enter your {Service} API Key above.',
- 'You can find your API key in {Service} at Settings > API.',
- `Click "Save Configuration" to automatically create the webhook in {Service} for ${eventType} events.`,
- 'The webhook will be automatically deleted when you remove this trigger.',
- ]
-
- return instructions
- .map((instruction, index) =>
- `${index + 1}. ${instruction}
`
- )
- .join('')
-}
-```
-
-#### 3. Add `createSubscription` and `deleteSubscription` to the Provider Handler
-
-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
-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.')
- }
-
- // 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 eventType = eventTypeMap[triggerId ?? '']
- const notificationUrl = getNotificationUrl(ctx.webhook)
-
- const requestBody: Record = {
- url: notificationUrl,
- }
- if (eventType) {
- requestBody.eventType = eventType
- }
-
- const response = await fetch('https://api.{service}.com/webhooks', {
- method: 'POST',
- headers: {
- Authorization: `Bearer ${apiKey}`,
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify(requestBody),
- })
-
- 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)
- }
-
- const externalId = responseBody.id as string | undefined
- if (!externalId) {
- throw new Error('{Service} webhook created but no ID was returned.')
- }
-
- 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
- }
- },
-
- 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 || !externalId) {
- logger.warn(`[${ctx.requestId}] Missing apiKey or externalId, skipping cleanup`)
- return
- }
-
- const response = await fetch(`https://api.{service}.com/webhooks/${externalId}`, {
- method: 'DELETE',
- headers: { Authorization: `Bearer ${apiKey}` },
- })
-
- 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}`)
- }
- } catch (error) {
- logger.warn(`[${ctx.requestId}] Error deleting {Service} webhook (non-fatal)`, error)
- }
- },
}
```
-#### How It Works
+## Provider Handler
-The orchestration layer handles everything automatically:
-
-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).
-
-You do NOT need to modify any orchestration files. Just implement the methods on your handler.
-
-#### Shared Utilities for Subscriptions
-
-Import from `@/lib/webhooks/providers/subscription-utils`:
-
-- `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**: 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
-
-This is the generic helper from `@/triggers` that creates consistent trigger subBlocks.
-
-### Function Signature
-
-```typescript
-interface BuildTriggerSubBlocksOptions {
- triggerId: string // e.g., 'service_event_a'
- triggerOptions: Array<{ label: string; id: string }> // Dropdown options
- includeDropdown?: boolean // true only for primary trigger
- setupInstructions: string // HTML instructions
- extraFields?: SubBlockConfig[] // Service-specific fields
- webhookPlaceholder?: string // Custom placeholder text
-}
-
-function buildTriggerSubBlocks(options: BuildTriggerSubBlocksOptions): SubBlockConfig[]
-```
-
-### What It Creates
-
-The helper creates this structure:
-1. **Dropdown** (only if `includeDropdown: true`) - Trigger type selector
-2. **Webhook URL** - Read-only field with copy button
-3. **Extra Fields** - Your service-specific fields (filters, options, etc.)
-4. **Save Button** - Activates the trigger
-5. **Instructions** - Setup guide for users
-
-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)
-```
+All provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`.
### When to Create a Handler
-| Behavior | Method to implement | Example providers |
+| Behavior | Method | Examples |
|---|---|---|
| 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 |
+| Event filtering | `matchEvent` | GitHub, Jira, Attio, HubSpot |
| 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`.
+| Custom input formatting | `formatInput` | Slack, Teams, Attio, Ashby |
+| Auto webhook creation | `createSubscription` | Ashby, Grain, Calendly, Airtable |
+| Auto webhook deletion | `deleteSubscription` | Ashby, Grain, Calendly, Airtable |
+| Challenge/verification | `handleChallenge` | Slack, WhatsApp, Teams |
+| Custom success response | `formatSuccessResponse` | Slack, Twilio Voice, Teams |
-### Simple Example: HMAC Auth Only
+If none apply, you don't need a handler. The default handler provides bearer token auth.
-Signature validators are defined as private functions **inside the handler file** (not in a shared utils file):
+### Example Handler
```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 type { EventMatchContext, FormatInputContext, FormatInputResult, 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
- }
+ if (!secret || !signature || !body) return false
+ const computed = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex')
+ return safeCompare(computed, signature)
}
export const {service}Handler: WebhookProviderHandler = {
@@ -612,35 +208,34 @@ export const {service}Handler: WebhookProviderHandler = {
providerLabel: '{Service}',
}),
- async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
+ async matchEvent({ 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
- }
+ if (!is{Service}EventMatch(triggerId, body as Record)) return false
}
-
return true
},
+ async formatInput({ body }: FormatInputContext): Promise {
+ const b = body as Record
+ return {
+ input: {
+ eventType: b.type,
+ resourceId: (b.data as Record)?.id || '',
+ resource: b.data,
+ },
+ }
+ },
+
extractIdempotencyId(body: unknown) {
const obj = body as Record
- if (obj.id && obj.type) {
- return `${obj.type}:${obj.id}`
- }
- return null
+ return obj.id && obj.type ? `${obj.type}:${obj.id}` : null
},
}
```
-### Registering the Handler
+### Register the Handler
In `apps/sim/lib/webhooks/providers/registry.ts`:
@@ -648,94 +243,82 @@ In `apps/sim/lib/webhooks/providers/registry.ts`:
import { {service}Handler } from '@/lib/webhooks/providers/{service}'
const PROVIDER_HANDLERS: Record = {
- // ... existing providers (alphabetical) ...
+ // ... existing (alphabetical) ...
{service}: {service}Handler,
}
```
-## Trigger Outputs & Webhook Input Formatting
+## Output Alignment (Critical)
-### Important: Two Sources of Truth
+There are two sources of truth that **MUST be aligned**:
-There are two related but separate concerns:
+1. **Trigger `outputs`** — schema defining what fields SHOULD be available (UI tag dropdown)
+2. **`formatInput` on the handler** — implementation that transforms raw payload into actual data
-1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown.
-2. **`formatInput` on the handler** - Implementation that transforms raw webhook payload into actual data. Defined in `apps/sim/lib/webhooks/providers/{service}.ts`.
+If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover.
-**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)
+**Rules for `formatInput`:**
+- Return `{ input: { ... } }` where inner keys match trigger `outputs` exactly
+- Return `{ input: ..., skip: { message: '...' } }` to skip execution
+- No wrapper objects or duplication
+- Use `null` for missing optional data
-### When to Add `formatInput`
+## Automatic Webhook Registration
-- **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 `formatInput` to Your Handler
-
-In `apps/sim/lib/webhooks/providers/{service}.ts`:
+If the service API supports programmatic webhook creation, implement `createSubscription` and `deleteSubscription` on the handler. The orchestration layer calls these automatically — **no code touches `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`**.
```typescript
-import type {
- FormatInputContext,
- FormatInputResult,
- WebhookProviderHandler,
-} from '@/lib/webhooks/providers/types'
+import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
+import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types'
export const {service}Handler: WebhookProviderHandler = {
- // ... other methods ...
+ async createSubscription(ctx: SubscriptionContext): Promise {
+ const config = getProviderConfig(ctx.webhook)
+ const apiKey = config.apiKey as string
+ if (!apiKey) throw new Error('{Service} API Key is required.')
+
+ const res = await fetch('https://api.{service}.com/webhooks', {
+ method: 'POST',
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }),
+ })
+
+ if (!res.ok) throw new Error(`{Service} error: ${res.status}`)
+ const { id } = (await res.json()) as { id: string }
+ return { providerConfigUpdates: { externalId: id } }
+ },
- 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,
- },
- }
+ async deleteSubscription(ctx: DeleteSubscriptionContext): Promise {
+ const config = getProviderConfig(ctx.webhook)
+ const { apiKey, externalId } = config as { apiKey?: string; externalId?: string }
+ if (!apiKey || !externalId) return
+ await fetch(`https://api.{service}.com/webhooks/${externalId}`, {
+ method: 'DELETE',
+ headers: { Authorization: `Bearer ${apiKey}` },
+ }).catch(() => {})
},
}
```
-**Key rules:**
-- 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
-
-### Verify Alignment
+**Key points:**
+- Throw from `createSubscription` — orchestration rolls back the DB webhook
+- Never throw from `deleteSubscription` — log non-fatally
+- Return `{ providerConfigUpdates: { externalId } }` — orchestration merges into `providerConfig`
+- Add `apiKey` field to `build{Service}ExtraFields` with `password: true`
-Run the alignment checker:
-```bash
-bunx scripts/check-trigger-alignment.ts {service}
-```
-
-## Trigger Outputs
+## Trigger Outputs Schema
Trigger outputs use the same schema as block outputs (NOT tool outputs).
-**Supported:**
-- `type` and `description` for simple fields
-- Nested object structure for complex data
-
-**NOT Supported:**
-- `optional: true` (tool outputs only)
-- `items` property (tool outputs only)
+**Supported:** `type` + `description` for leaf fields, nested objects for complex data.
+**NOT supported:** `optional: true`, `items` (those are tool-output-only features).
```typescript
export function buildOutputs(): Record {
return {
- // Simple fields
eventType: { type: 'string', description: 'Event type' },
timestamp: { type: 'string', description: 'When it occurred' },
-
- // Complex data - use type: 'json'
payload: { type: 'json', description: 'Full event payload' },
-
- // Nested structure
resource: {
id: { type: 'string', description: 'Resource ID' },
name: { type: 'string', description: 'Resource name' },
@@ -744,83 +327,32 @@ export function buildOutputs(): Record {
}
```
-## Generic Webhook Trigger Pattern
-
-For services with many event types, create a generic webhook that accepts all events:
-
-```typescript
-export const {service}WebhookTrigger: TriggerConfig = {
- id: '{service}_webhook',
- name: '{Service} Webhook (All Events)',
- // ...
-
- subBlocks: buildTriggerSubBlocks({
- triggerId: '{service}_webhook',
- triggerOptions: {service}TriggerOptions,
- setupInstructions: {service}SetupInstructions('All Events'),
- extraFields: [
- // Event type filter (optional)
- {
- id: 'eventTypes',
- title: 'Event Types',
- type: 'dropdown',
- multiSelect: true,
- options: [
- { label: 'Event A', id: 'event_a' },
- { label: 'Event B', id: 'event_b' },
- ],
- placeholder: 'Leave empty for all events',
- mode: 'trigger',
- condition: { field: 'selectedTriggerId', value: '{service}_webhook' },
- },
- // Plus any other service-specific fields
- ...build{Service}ExtraFields('{service}_webhook'),
- ],
- }),
-}
-```
-
-## Checklist Before Finishing
-
-### Utils
-- [ ] Created `{service}TriggerOptions` array with all trigger IDs
-- [ ] Created `{service}SetupInstructions` function with clear steps
-- [ ] Created `build{Service}ExtraFields` for service-specific fields
-- [ ] Created output builders for each trigger type
+## Checklist
-### Triggers
-- [ ] Primary trigger has `includeDropdown: true`
-- [ ] Secondary triggers do NOT have `includeDropdown`
+### Trigger Definition
+- [ ] Created `utils.ts` with options, instructions, extra fields, and output builders
+- [ ] Primary trigger has `includeDropdown: true`; secondary triggers do NOT
- [ ] All triggers use `buildTriggerSubBlocks` helper
-- [ ] All triggers have proper outputs defined
- [ ] Created `index.ts` barrel export
### Registration
-- [ ] All triggers imported in `triggers/registry.ts`
-- [ ] All triggers added to `TRIGGER_REGISTRY`
-- [ ] Block has `triggers.enabled: true`
-- [ ] Block has all trigger IDs in `triggers.available`
+- [ ] All triggers in `triggers/registry.ts` → `TRIGGER_REGISTRY`
+- [ ] Block has `triggers.enabled: true` and lists 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
+### Provider Handler (if needed)
+- [ ] Handler file at `apps/sim/lib/webhooks/providers/{service}.ts`
+- [ ] Registered in `providers/registry.ts` (alphabetical)
+- [ ] Signature validator is a private function inside the handler file
+- [ ] `formatInput` output keys match trigger `outputs` exactly
- [ ] 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 `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`
+### Auto Registration (if supported)
+- [ ] `createSubscription` and `deleteSubscription` on the handler
+- [ ] NO changes to `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
+- [ ] API key field uses `password: true`
### 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)
+- [ ] `bun run type-check` passes
+- [ ] Manually verify `formatInput` output keys match trigger `outputs` keys
+- [ ] Trigger UI shows correctly in the block
diff --git a/.claude/commands/validate-trigger.md b/.claude/commands/validate-trigger.md
new file mode 100644
index 00000000000..04bdc63c397
--- /dev/null
+++ b/.claude/commands/validate-trigger.md
@@ -0,0 +1,212 @@
+---
+description: Validate an existing Sim webhook trigger against provider API docs and repository conventions
+argument-hint: [api-docs-url]
+---
+
+# Validate Trigger
+
+You are an expert auditor for Sim webhook triggers. Your job is to validate that an existing trigger implementation is correct, complete, secure, and aligned across all layers.
+
+## Your Task
+
+1. Read the service's webhook/API documentation (via WebFetch)
+2. Read every trigger file, provider handler, and registry entry
+3. Cross-reference against the API docs and Sim conventions
+4. Report all issues grouped by severity (critical, warning, suggestion)
+5. Fix all issues after reporting them
+
+## Step 1: Gather All Files
+
+Read **every** file for the trigger — do not skip any:
+
+```
+apps/sim/triggers/{service}/ # All trigger files, utils.ts, index.ts
+apps/sim/lib/webhooks/providers/{service}.ts # Provider handler (if exists)
+apps/sim/lib/webhooks/providers/registry.ts # Handler registry
+apps/sim/triggers/registry.ts # Trigger registry
+apps/sim/blocks/blocks/{service}.ts # Block definition (trigger wiring)
+```
+
+Also read for reference:
+```
+apps/sim/lib/webhooks/providers/types.ts # WebhookProviderHandler interface
+apps/sim/lib/webhooks/providers/utils.ts # Shared helpers (createHmacVerifier, etc.)
+apps/sim/lib/webhooks/provider-subscription-utils.ts # Subscription helpers
+apps/sim/lib/webhooks/processor.ts # Central webhook processor
+```
+
+## Step 2: Pull API Documentation
+
+Fetch the service's official webhook documentation. This is the **source of truth** for:
+- Webhook event types and payload shapes
+- Signature/auth verification method (HMAC algorithm, header names, secret format)
+- Challenge/verification handshake requirements
+- Webhook subscription API (create/delete endpoints, if applicable)
+- Retry behavior and delivery guarantees
+
+## Step 3: Validate Trigger Definitions
+
+### utils.ts
+- [ ] `{service}TriggerOptions` lists all trigger IDs accurately
+- [ ] `{service}SetupInstructions` provides clear, correct steps for the service
+- [ ] `build{Service}ExtraFields` includes relevant filter/config fields with correct `condition`
+- [ ] Output builders expose all meaningful fields from the webhook payload
+- [ ] Output builders do NOT use `optional: true` or `items` (tool-output-only features)
+- [ ] Nested output objects correctly model the payload structure
+
+### Trigger Files
+- [ ] Exactly one primary trigger has `includeDropdown: true`
+- [ ] All secondary triggers do NOT have `includeDropdown`
+- [ ] All triggers use `buildTriggerSubBlocks` helper (not hand-rolled subBlocks)
+- [ ] Every trigger's `id` matches the convention `{service}_{event_name}`
+- [ ] Every trigger's `provider` matches the service name used in the handler registry
+- [ ] `index.ts` barrel exports all triggers
+
+### Trigger ↔ Provider Alignment (CRITICAL)
+- [ ] Every trigger ID referenced in `matchEvent` logic exists in `{service}TriggerOptions`
+- [ ] Event matching logic in the provider correctly maps trigger IDs to service event types
+- [ ] Event matching logic in `is{Service}EventMatch` (if exists) correctly identifies events per the API docs
+
+## Step 4: Validate Provider Handler
+
+### Auth Verification
+- [ ] `verifyAuth` correctly validates webhook signatures per the service's documentation
+- [ ] HMAC algorithm matches (SHA-1, SHA-256, SHA-512)
+- [ ] Signature header name matches the API docs exactly
+- [ ] Signature format is handled (raw hex, `sha256=` prefix, base64, etc.)
+- [ ] Uses `safeCompare` for timing-safe comparison (no `===`)
+- [ ] If `webhookSecret` is required, handler rejects when it's missing (fail-closed)
+- [ ] Signature is computed over raw body (not parsed JSON)
+
+### Event Matching
+- [ ] `matchEvent` returns `boolean` (not `NextResponse` or other values)
+- [ ] Challenge/verification events are excluded from matching (e.g., `endpoint.url_validation`)
+- [ ] When `triggerId` is a generic webhook ID, all events pass through
+- [ ] When `triggerId` is specific, only matching events pass
+- [ ] Event matching logic uses dynamic `await import()` for trigger utils (avoids circular deps)
+
+### formatInput (CRITICAL)
+- [ ] Every key in the `formatInput` return matches a key in the trigger `outputs` schema
+- [ ] Every key in the trigger `outputs` schema is populated by `formatInput`
+- [ ] No extra undeclared keys that users can't discover in the UI
+- [ ] No wrapper objects (`webhook: { ... }`, `{service}: { ... }`)
+- [ ] Nested output paths exist at the correct depth (e.g., `resource.id` actually has `resource: { id: ... }`)
+- [ ] `null` is used for missing optional fields (not empty strings or empty objects)
+- [ ] Returns `{ input: { ... } }` — not a bare object
+
+### Idempotency
+- [ ] `extractIdempotencyId` returns a stable, unique key per delivery
+- [ ] Uses provider-specific delivery IDs when available (e.g., `X-Request-Id`, `Linear-Delivery`, `svix-id`)
+- [ ] Falls back to content-based ID (e.g., `${type}:${id}`) when no delivery header exists
+- [ ] Does NOT include timestamps in the idempotency key (would break dedup on retries)
+
+### Challenge Handling (if applicable)
+- [ ] `handleChallenge` correctly implements the service's URL verification handshake
+- [ ] Returns the expected response format per the API docs
+- [ ] Env-backed secrets are resolved via `resolveEnvVarsInObject` if needed
+
+## Step 5: Validate Automatic Subscription Lifecycle
+
+If the service supports programmatic webhook creation:
+
+### createSubscription
+- [ ] Calls the correct API endpoint to create a webhook
+- [ ] Sends the correct event types/filters
+- [ ] Passes the notification URL from `getNotificationUrl(ctx.webhook)`
+- [ ] Returns `{ providerConfigUpdates: { externalId } }` with the external webhook ID
+- [ ] Throws on failure (orchestration handles rollback)
+- [ ] Provides user-friendly error messages (401 → "Invalid API Key", etc.)
+
+### deleteSubscription
+- [ ] Calls the correct API endpoint to delete the webhook
+- [ ] Handles 404 gracefully (webhook already deleted)
+- [ ] Never throws — catches errors and logs non-fatally
+- [ ] Skips gracefully when `apiKey` or `externalId` is missing
+
+### Orchestration Isolation
+- [ ] NO provider-specific logic in `route.ts`, `provider-subscriptions.ts`, or `deploy.ts`
+- [ ] All subscription logic lives on the handler (`createSubscription`/`deleteSubscription`)
+
+## Step 6: Validate Registration and Block Wiring
+
+### Trigger Registry (`triggers/registry.ts`)
+- [ ] All triggers are imported and registered
+- [ ] Registry keys match trigger IDs exactly
+- [ ] No orphaned entries (triggers that don't exist)
+
+### Provider Handler Registry (`providers/registry.ts`)
+- [ ] Handler is imported and registered (if handler exists)
+- [ ] Registry key matches the `provider` field on the trigger configs
+- [ ] Entries are in alphabetical order
+
+### Block Wiring (`blocks/blocks/{service}.ts`)
+- [ ] Block has `triggers.enabled: true`
+- [ ] `triggers.available` lists all trigger IDs
+- [ ] All trigger subBlocks are spread into `subBlocks`: `...getTrigger('id').subBlocks`
+- [ ] No trigger IDs in `triggers.available` that aren't in the registry
+- [ ] No trigger subBlocks spread that aren't in `triggers.available`
+
+## Step 7: Validate Security
+
+- [ ] Webhook secrets are never logged (not even at debug level)
+- [ ] Auth verification runs before any event processing
+- [ ] No secret comparison uses `===` (must use `safeCompare` or `crypto.timingSafeEqual`)
+- [ ] Timestamp/replay protection is reasonable (not too tight for retries, not too loose for security)
+- [ ] Raw body is used for signature verification (not re-serialized JSON)
+
+## Step 8: Report and Fix
+
+### Report Format
+
+Group findings by severity:
+
+**Critical** (runtime errors, security issues, or data loss):
+- Wrong HMAC algorithm or header name
+- `formatInput` keys don't match trigger `outputs`
+- Missing `verifyAuth` when the service sends signed webhooks
+- `matchEvent` returns non-boolean values
+- Provider-specific logic leaking into shared orchestration files
+- Trigger IDs mismatch between trigger files, registry, and block
+- `createSubscription` calling wrong API endpoint
+- Auth comparison using `===` instead of `safeCompare`
+
+**Warning** (convention violations or usability issues):
+- Missing `extractIdempotencyId` when the service provides delivery IDs
+- Timestamps in idempotency keys (breaks dedup on retries)
+- Missing challenge handling when the service requires URL verification
+- Output schema missing fields that `formatInput` returns (undiscoverable data)
+- Overly tight timestamp skew window that rejects legitimate retries
+- `matchEvent` not filtering challenge/verification events
+- Setup instructions missing important steps
+
+**Suggestion** (minor improvements):
+- More specific output field descriptions
+- Additional output fields that could be exposed
+- Better error messages in `createSubscription`
+- Logging improvements
+
+### Fix All Issues
+
+After reporting, fix every **critical** and **warning** issue. Apply **suggestions** where they don't add unnecessary complexity.
+
+### Validation Output
+
+After fixing, confirm:
+1. `bun run type-check` passes
+2. Re-read all modified files to verify fixes are correct
+3. Provider handler tests pass (if they exist): `bun test {service}`
+
+## Checklist Summary
+
+- [ ] Read all trigger files, provider handler, types, registries, and block
+- [ ] Pulled and read official webhook/API documentation
+- [ ] Validated trigger definitions: options, instructions, extra fields, outputs
+- [ ] Validated primary/secondary trigger distinction (`includeDropdown`)
+- [ ] Validated provider handler: auth, matchEvent, formatInput, idempotency
+- [ ] Validated output alignment: every `outputs` key ↔ every `formatInput` key
+- [ ] Validated subscription lifecycle: createSubscription, deleteSubscription, no shared-file edits
+- [ ] Validated registration: trigger registry, handler registry, block wiring
+- [ ] Validated security: safe comparison, no secret logging, replay protection
+- [ ] Reported all issues grouped by severity
+- [ ] Fixed all critical and warning issues
+- [ ] `bun run type-check` passes after fixes
diff --git a/apps/docs/components/ui/icon-mapping.ts b/apps/docs/components/ui/icon-mapping.ts
index b5097ba8eb3..c65d55ed9d4 100644
--- a/apps/docs/components/ui/icon-mapping.ts
+++ b/apps/docs/components/ui/icon-mapping.ts
@@ -284,7 +284,7 @@ export const blockTypeToIconMap: Record = {
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
- linear: LinearIcon,
+ linear_v2: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
loops: LoopsIcon,
diff --git a/apps/docs/content/docs/en/tools/linear.mdx b/apps/docs/content/docs/en/tools/linear.mdx
index 2e6c87677e7..9a23938192e 100644
--- a/apps/docs/content/docs/en/tools/linear.mdx
+++ b/apps/docs/content/docs/en/tools/linear.mdx
@@ -6,7 +6,7 @@ description: Interact with Linear issues, projects, and more
import { BlockInfoCard } from "@/components/ui/block-info-card"
diff --git a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts
index 603fecd3633..503242d8c1e 100644
--- a/apps/sim/app/(landing)/integrations/data/icon-mapping.ts
+++ b/apps/sim/app/(landing)/integrations/data/icon-mapping.ts
@@ -284,7 +284,7 @@ export const blockTypeToIconMap: Record = {
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
- linear: LinearIcon,
+ linear_v2: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
loops: LoopsIcon,
diff --git a/apps/sim/app/(landing)/integrations/data/integrations.json b/apps/sim/app/(landing)/integrations/data/integrations.json
index 3115586f033..9a89658f1ff 100644
--- a/apps/sim/app/(landing)/integrations/data/integrations.json
+++ b/apps/sim/app/(landing)/integrations/data/integrations.json
@@ -5264,8 +5264,49 @@
}
],
"operationCount": 11,
- "triggers": [],
- "triggerCount": 0,
+ "triggers": [
+ {
+ "id": "greenhouse_candidate_hired",
+ "name": "Greenhouse Candidate Hired",
+ "description": "Trigger workflow when a candidate is hired"
+ },
+ {
+ "id": "greenhouse_new_application",
+ "name": "Greenhouse New Application",
+ "description": "Trigger workflow when a new application is submitted"
+ },
+ {
+ "id": "greenhouse_candidate_stage_change",
+ "name": "Greenhouse Candidate Stage Change",
+ "description": "Trigger workflow when a candidate changes interview stages"
+ },
+ {
+ "id": "greenhouse_candidate_rejected",
+ "name": "Greenhouse Candidate Rejected",
+ "description": "Trigger workflow when a candidate is rejected"
+ },
+ {
+ "id": "greenhouse_offer_created",
+ "name": "Greenhouse Offer Created",
+ "description": "Trigger workflow when a new offer is created"
+ },
+ {
+ "id": "greenhouse_job_created",
+ "name": "Greenhouse Job Created",
+ "description": "Trigger workflow when a new job is created"
+ },
+ {
+ "id": "greenhouse_job_updated",
+ "name": "Greenhouse Job Updated",
+ "description": "Trigger workflow when a job is updated"
+ },
+ {
+ "id": "greenhouse_webhook",
+ "name": "Greenhouse Webhook (Endpoint Events)",
+ "description": "Trigger on whichever event types you select for this URL in Greenhouse. Sim does not filter deliveries for this trigger."
+ }
+ ],
+ "triggerCount": 8,
"authType": "api-key",
"category": "tools",
"integrationType": "hr",
@@ -6818,7 +6859,7 @@
"tags": ["sales-engagement", "email-marketing", "automation"]
},
{
- "type": "linear",
+ "type": "linear_v2",
"slug": "linear",
"name": "Linear",
"description": "Interact with Linear issues, projects, and more",
@@ -7143,79 +7184,79 @@
"operationCount": 78,
"triggers": [
{
- "id": "linear_issue_created",
+ "id": "linear_issue_created_v2",
"name": "Linear Issue Created",
"description": "Trigger workflow when a new issue is created in Linear"
},
{
- "id": "linear_issue_updated",
+ "id": "linear_issue_updated_v2",
"name": "Linear Issue Updated",
"description": "Trigger workflow when an issue is updated in Linear"
},
{
- "id": "linear_issue_removed",
+ "id": "linear_issue_removed_v2",
"name": "Linear Issue Removed",
"description": "Trigger workflow when an issue is removed/deleted in Linear"
},
{
- "id": "linear_comment_created",
+ "id": "linear_comment_created_v2",
"name": "Linear Comment Created",
"description": "Trigger workflow when a new comment is created in Linear"
},
{
- "id": "linear_comment_updated",
+ "id": "linear_comment_updated_v2",
"name": "Linear Comment Updated",
"description": "Trigger workflow when a comment is updated in Linear"
},
{
- "id": "linear_project_created",
+ "id": "linear_project_created_v2",
"name": "Linear Project Created",
"description": "Trigger workflow when a new project is created in Linear"
},
{
- "id": "linear_project_updated",
+ "id": "linear_project_updated_v2",
"name": "Linear Project Updated",
"description": "Trigger workflow when a project is updated in Linear"
},
{
- "id": "linear_cycle_created",
+ "id": "linear_cycle_created_v2",
"name": "Linear Cycle Created",
"description": "Trigger workflow when a new cycle is created in Linear"
},
{
- "id": "linear_cycle_updated",
+ "id": "linear_cycle_updated_v2",
"name": "Linear Cycle Updated",
"description": "Trigger workflow when a cycle is updated in Linear"
},
{
- "id": "linear_label_created",
+ "id": "linear_label_created_v2",
"name": "Linear Label Created",
"description": "Trigger workflow when a new label is created in Linear"
},
{
- "id": "linear_label_updated",
+ "id": "linear_label_updated_v2",
"name": "Linear Label Updated",
"description": "Trigger workflow when a label is updated in Linear"
},
{
- "id": "linear_project_update_created",
+ "id": "linear_project_update_created_v2",
"name": "Linear Project Update Created",
"description": "Trigger workflow when a new project update is posted in Linear"
},
{
- "id": "linear_customer_request_created",
+ "id": "linear_customer_request_created_v2",
"name": "Linear Customer Request Created",
"description": "Trigger workflow when a new customer request is created in Linear"
},
{
- "id": "linear_customer_request_updated",
+ "id": "linear_customer_request_updated_v2",
"name": "Linear Customer Request Updated",
"description": "Trigger workflow when a customer request is updated in Linear"
},
{
- "id": "linear_webhook",
+ "id": "linear_webhook_v2",
"name": "Linear Webhook",
- "description": "Trigger workflow from any Linear webhook event"
+ "description": "Trigger workflow from Linear data-change events included in this webhook subscription (Issues, Comments, Projects, etc.—not every Linear model)."
}
],
"triggerCount": 15,
@@ -9561,8 +9602,49 @@
}
],
"operationCount": 8,
- "triggers": [],
- "triggerCount": 0,
+ "triggers": [
+ {
+ "id": "resend_email_sent",
+ "name": "Resend Email Sent",
+ "description": "Trigger workflow when an email is sent"
+ },
+ {
+ "id": "resend_email_delivered",
+ "name": "Resend Email Delivered",
+ "description": "Trigger workflow when an email is delivered"
+ },
+ {
+ "id": "resend_email_bounced",
+ "name": "Resend Email Bounced",
+ "description": "Trigger workflow when an email bounces"
+ },
+ {
+ "id": "resend_email_complained",
+ "name": "Resend Email Complained",
+ "description": "Trigger workflow when an email is marked as spam"
+ },
+ {
+ "id": "resend_email_opened",
+ "name": "Resend Email Opened",
+ "description": "Trigger workflow when an email is opened"
+ },
+ {
+ "id": "resend_email_clicked",
+ "name": "Resend Email Clicked",
+ "description": "Trigger workflow when a link in an email is clicked"
+ },
+ {
+ "id": "resend_email_failed",
+ "name": "Resend Email Failed",
+ "description": "Trigger workflow when an email fails to send"
+ },
+ {
+ "id": "resend_webhook",
+ "name": "Resend Webhook (All Events)",
+ "description": "Trigger on Resend webhook events we subscribe to (email lifecycle, contacts, domains—see Resend docs). Flattened email fields may be null for non-email events; use data for the full payload."
+ }
+ ],
+ "triggerCount": 8,
"authType": "none",
"category": "tools",
"integrationType": "email",
@@ -12129,8 +12211,49 @@
}
],
"operationCount": 50,
- "triggers": [],
- "triggerCount": 0,
+ "triggers": [
+ {
+ "id": "vercel_deployment_created",
+ "name": "Vercel Deployment Created",
+ "description": "Trigger workflow when a new deployment is created"
+ },
+ {
+ "id": "vercel_deployment_ready",
+ "name": "Vercel Deployment Ready",
+ "description": "Trigger workflow when a deployment is ready to serve traffic"
+ },
+ {
+ "id": "vercel_deployment_error",
+ "name": "Vercel Deployment Error",
+ "description": "Trigger workflow when a deployment fails"
+ },
+ {
+ "id": "vercel_deployment_canceled",
+ "name": "Vercel Deployment Canceled",
+ "description": "Trigger workflow when a deployment is canceled"
+ },
+ {
+ "id": "vercel_project_created",
+ "name": "Vercel Project Created",
+ "description": "Trigger workflow when a new project is created"
+ },
+ {
+ "id": "vercel_project_removed",
+ "name": "Vercel Project Removed",
+ "description": "Trigger workflow when a project is removed"
+ },
+ {
+ "id": "vercel_domain_created",
+ "name": "Vercel Domain Created",
+ "description": "Trigger workflow when a domain is created"
+ },
+ {
+ "id": "vercel_webhook",
+ "name": "Vercel Webhook (Common Events)",
+ "description": "Trigger on a curated set of common Vercel events (deployments, projects, domains, edge config). Pick a specific trigger to listen to one event type only."
+ }
+ ],
+ "triggerCount": 8,
"authType": "api-key",
"category": "tools",
"integrationType": "developer-tools",
@@ -12935,33 +13058,33 @@
"triggers": [
{
"id": "zoom_meeting_started",
- "name": "Meeting Started",
- "description": "Triggered when a Zoom meeting starts"
+ "name": "Zoom Meeting Started",
+ "description": "Trigger workflow when a Zoom meeting starts"
},
{
"id": "zoom_meeting_ended",
- "name": "Meeting Ended",
- "description": "Triggered when a Zoom meeting ends"
+ "name": "Zoom Meeting Ended",
+ "description": "Trigger workflow when a Zoom meeting ends"
},
{
"id": "zoom_participant_joined",
- "name": "Participant Joined",
- "description": "Triggered when a participant joins a Zoom meeting"
+ "name": "Zoom Participant Joined",
+ "description": "Trigger workflow when a participant joins a Zoom meeting"
},
{
"id": "zoom_participant_left",
- "name": "Participant Left",
- "description": "Triggered when a participant leaves a Zoom meeting"
+ "name": "Zoom Participant Left",
+ "description": "Trigger workflow when a participant leaves a Zoom meeting"
},
{
"id": "zoom_recording_completed",
- "name": "Recording Completed",
- "description": "Triggered when a Zoom cloud recording is completed"
+ "name": "Zoom Recording Completed",
+ "description": "Trigger workflow when a Zoom cloud recording is completed"
},
{
"id": "zoom_webhook",
- "name": "Generic Webhook",
- "description": "Triggered on any Zoom webhook event"
+ "name": "Zoom Webhook (All Events)",
+ "description": "Trigger workflow on any Zoom webhook event"
}
],
"triggerCount": 6,
diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
index a04c749af50..46ec98d4735 100644
--- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts
+++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts
@@ -76,7 +76,7 @@ async function handleWebhookPost(
const { body, rawBody } = parseResult
- const challengeResponse = await handleProviderChallenges(body, request, requestId, path)
+ const challengeResponse = await handleProviderChallenges(body, request, requestId, path, rawBody)
if (challengeResponse) {
return challengeResponse
}
diff --git a/apps/sim/blocks/blocks/zoom.ts b/apps/sim/blocks/blocks/zoom.ts
index 5c77ac856ed..c699431a643 100644
--- a/apps/sim/blocks/blocks/zoom.ts
+++ b/apps/sim/blocks/blocks/zoom.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 { ZoomResponse } from '@/tools/zoom/types'
+import { getTrigger } from '@/triggers'
export const ZoomBlock: BlockConfig = {
type: 'zoom',
@@ -17,6 +18,17 @@ export const ZoomBlock: BlockConfig = {
tags: ['meeting', 'calendar', 'scheduling'],
bgColor: '#2D8CFF',
icon: ZoomIcon,
+ triggers: {
+ enabled: true,
+ available: [
+ 'zoom_meeting_started',
+ 'zoom_meeting_ended',
+ 'zoom_participant_joined',
+ 'zoom_participant_left',
+ 'zoom_recording_completed',
+ 'zoom_webhook',
+ ],
+ },
subBlocks: [
{
id: 'operation',
@@ -440,6 +452,12 @@ Return ONLY the date string - no explanations, no quotes, no extra text.`,
value: ['zoom_delete_meeting'],
},
},
+ ...getTrigger('zoom_meeting_started').subBlocks,
+ ...getTrigger('zoom_meeting_ended').subBlocks,
+ ...getTrigger('zoom_participant_joined').subBlocks,
+ ...getTrigger('zoom_participant_left').subBlocks,
+ ...getTrigger('zoom_recording_completed').subBlocks,
+ ...getTrigger('zoom_webhook').subBlocks,
],
tools: {
access: [
diff --git a/apps/sim/lib/core/idempotency/service.test.ts b/apps/sim/lib/core/idempotency/service.test.ts
new file mode 100644
index 00000000000..52ef9e7d019
--- /dev/null
+++ b/apps/sim/lib/core/idempotency/service.test.ts
@@ -0,0 +1,38 @@
+/**
+ * @vitest-environment node
+ */
+
+import { describe, expect, it } from 'vitest'
+import { IdempotencyService } from '@/lib/core/idempotency/service'
+
+describe('IdempotencyService.createWebhookIdempotencyKey', () => {
+ it('uses Greenhouse-Event-ID when present', () => {
+ const key = IdempotencyService.createWebhookIdempotencyKey(
+ 'wh_1',
+ { 'greenhouse-event-id': 'evt-gh-99' },
+ {},
+ 'greenhouse'
+ )
+ expect(key).toBe('wh_1:evt-gh-99')
+ })
+
+ it('prefers svix-id for Resend / Svix duplicate delivery deduplication', () => {
+ const key = IdempotencyService.createWebhookIdempotencyKey(
+ 'wh_1',
+ { 'svix-id': 'msg_abc123' },
+ { type: 'email.sent' },
+ 'resend'
+ )
+ expect(key).toBe('wh_1:msg_abc123')
+ })
+
+ it('prefers Linear-Delivery so repeated updates to the same entity are not treated as one idempotent run', () => {
+ const key = IdempotencyService.createWebhookIdempotencyKey(
+ 'wh_linear',
+ { 'linear-delivery': '234d1a4e-b617-4388-90fe-adc3633d6b72' },
+ { action: 'update', data: { id: 'shared-entity-id' } },
+ 'linear'
+ )
+ expect(key).toBe('wh_linear:234d1a4e-b617-4388-90fe-adc3633d6b72')
+ })
+})
diff --git a/apps/sim/lib/core/idempotency/service.ts b/apps/sim/lib/core/idempotency/service.ts
index 27d0746e2a9..9c4961bd274 100644
--- a/apps/sim/lib/core/idempotency/service.ts
+++ b/apps/sim/lib/core/idempotency/service.ts
@@ -419,7 +419,10 @@ export class IdempotencyService {
normalizedHeaders?.['x-shopify-webhook-id'] ||
normalizedHeaders?.['x-github-delivery'] ||
normalizedHeaders?.['x-event-id'] ||
- normalizedHeaders?.['x-teams-notification-id']
+ normalizedHeaders?.['x-teams-notification-id'] ||
+ normalizedHeaders?.['svix-id'] ||
+ normalizedHeaders?.['linear-delivery'] ||
+ normalizedHeaders?.['greenhouse-event-id']
if (webhookIdHeader) {
return `${webhookId}:${webhookIdHeader}`
diff --git a/apps/sim/lib/webhooks/pending-verification.ts b/apps/sim/lib/webhooks/pending-verification.ts
index 4d77d35bd24..02c50204f92 100644
--- a/apps/sim/lib/webhooks/pending-verification.ts
+++ b/apps/sim/lib/webhooks/pending-verification.ts
@@ -47,6 +47,7 @@ const pendingWebhookVerificationRegistrationMatchers: Record<
ashby: () => true,
grain: () => true,
generic: (registration) => registration.metadata?.verifyTestEvents === true,
+ salesforce: () => true,
}
const pendingWebhookVerificationProbeMatchers: Record<
@@ -62,6 +63,10 @@ const pendingWebhookVerificationProbeMatchers: Record<
method === 'GET' ||
method === 'HEAD' ||
(method === 'POST' && (!body || Object.keys(body).length === 0)),
+ salesforce: ({ method, body }) =>
+ method === 'GET' ||
+ method === 'HEAD' ||
+ (method === 'POST' && (!body || Object.keys(body).length === 0)),
}
function getRedisKey(path: string): string {
diff --git a/apps/sim/lib/webhooks/processor.ts b/apps/sim/lib/webhooks/processor.ts
index 4288d0287d6..eb1503c0029 100644
--- a/apps/sim/lib/webhooks/processor.ts
+++ b/apps/sim/lib/webhooks/processor.ts
@@ -123,12 +123,13 @@ export async function handleProviderChallenges(
body: unknown,
request: NextRequest,
requestId: string,
- path: string
+ path: string,
+ rawBody?: string
): Promise {
for (const provider of CHALLENGE_PROVIDERS) {
const handler = getProviderHandler(provider)
if (handler.handleChallenge) {
- const response = await handler.handleChallenge(body, request, requestId, path)
+ const response = await handler.handleChallenge(body, request, requestId, path, rawBody)
if (response) {
return response
}
diff --git a/apps/sim/lib/webhooks/providers/subscription-utils.ts b/apps/sim/lib/webhooks/provider-subscription-utils.ts
similarity index 76%
rename from apps/sim/lib/webhooks/providers/subscription-utils.ts
rename to apps/sim/lib/webhooks/provider-subscription-utils.ts
index 17c6ca29514..e52e1eeefa1 100644
--- a/apps/sim/lib/webhooks/providers/subscription-utils.ts
+++ b/apps/sim/lib/webhooks/provider-subscription-utils.ts
@@ -7,14 +7,23 @@ import { resolveOAuthAccountId } from '@/app/api/auth/oauth/utils'
const logger = createLogger('WebhookProviderSubscriptions')
+/** Safely read a webhook row's provider config as a plain object. */
export function getProviderConfig(webhook: Record): Record {
return (webhook.providerConfig as Record) || {}
}
+/** Build the public callback URL providers should deliver webhook events to. */
export function getNotificationUrl(webhook: Record): string {
return `${getBaseUrl()}/api/webhooks/trigger/${webhook.path}`
}
+/**
+ * Resolve an OAuth-backed credential to the owning user and account.
+ *
+ * Provider subscription handlers use this when they need to refresh tokens or
+ * make provider API calls on behalf of the credential owner during webhook
+ * registration and cleanup.
+ */
export async function getCredentialOwner(
credentialId: string,
requestId: string
diff --git a/apps/sim/lib/webhooks/provider-subscriptions.ts b/apps/sim/lib/webhooks/provider-subscriptions.ts
index 0d9906e378a..cbf6a05f184 100644
--- a/apps/sim/lib/webhooks/provider-subscriptions.ts
+++ b/apps/sim/lib/webhooks/provider-subscriptions.ts
@@ -31,6 +31,13 @@ const SYSTEM_MANAGED_FIELDS = new Set([
'userId',
])
+/**
+ * Determine whether a webhook with provider-managed registration should be
+ * recreated after its persisted provider config changes.
+ *
+ * Only user-controlled fields are considered; provider-managed fields such as
+ * external IDs and generated secrets are ignored.
+ */
export function shouldRecreateExternalWebhookSubscription({
previousProvider,
nextProvider,
@@ -69,6 +76,13 @@ export function shouldRecreateExternalWebhookSubscription({
return false
}
+/**
+ * Ask the provider handler to create an external webhook subscription, if that
+ * provider supports automatic registration.
+ *
+ * The returned provider-managed fields are merged back into `providerConfig`
+ * by the caller.
+ */
export async function createExternalWebhookSubscription(
request: NextRequest,
webhookData: Record,
diff --git a/apps/sim/lib/webhooks/providers/airtable.ts b/apps/sim/lib/webhooks/providers/airtable.ts
index 80fecf73854..61daafa4f30 100644
--- a/apps/sim/lib/webhooks/providers/airtable.ts
+++ b/apps/sim/lib/webhooks/providers/airtable.ts
@@ -8,7 +8,7 @@ import {
getCredentialOwner,
getNotificationUrl,
getProviderConfig,
-} from '@/lib/webhooks/providers/subscription-utils'
+} from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
FormatInputContext,
diff --git a/apps/sim/lib/webhooks/providers/ashby.ts b/apps/sim/lib/webhooks/providers/ashby.ts
index ce044495009..b89d516c5cc 100644
--- a/apps/sim/lib/webhooks/providers/ashby.ts
+++ b/apps/sim/lib/webhooks/providers/ashby.ts
@@ -2,7 +2,7 @@ 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 { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
FormatInputContext,
diff --git a/apps/sim/lib/webhooks/providers/attio.ts b/apps/sim/lib/webhooks/providers/attio.ts
index 883d979334f..84c6b740780 100644
--- a/apps/sim/lib/webhooks/providers/attio.ts
+++ b/apps/sim/lib/webhooks/providers/attio.ts
@@ -3,7 +3,7 @@ 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 { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
diff --git a/apps/sim/lib/webhooks/providers/calendly.ts b/apps/sim/lib/webhooks/providers/calendly.ts
index 7fcca4a8e8f..a85b108c5bf 100644
--- a/apps/sim/lib/webhooks/providers/calendly.ts
+++ b/apps/sim/lib/webhooks/providers/calendly.ts
@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
-import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
+import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
FormatInputContext,
diff --git a/apps/sim/lib/webhooks/providers/fathom.ts b/apps/sim/lib/webhooks/providers/fathom.ts
index c705d00353f..c158c73e369 100644
--- a/apps/sim/lib/webhooks/providers/fathom.ts
+++ b/apps/sim/lib/webhooks/providers/fathom.ts
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
-import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
+import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
SubscriptionContext,
diff --git a/apps/sim/lib/webhooks/providers/gong.test.ts b/apps/sim/lib/webhooks/providers/gong.test.ts
new file mode 100644
index 00000000000..d6841cf6194
--- /dev/null
+++ b/apps/sim/lib/webhooks/providers/gong.test.ts
@@ -0,0 +1,167 @@
+import { createHash } from 'node:crypto'
+import * as jose from 'jose'
+import { NextRequest } from 'next/server'
+import { describe, expect, it } from 'vitest'
+import {
+ GONG_JWT_PUBLIC_KEY_CONFIG_KEY,
+ gongHandler,
+ normalizeGongPublicKeyPem,
+ verifyGongJwtAuth,
+} from '@/lib/webhooks/providers/gong'
+
+describe('normalizeGongPublicKeyPem', () => {
+ it('passes through PEM', () => {
+ const pem = '-----BEGIN PUBLIC KEY-----\nabc\n-----END PUBLIC KEY-----'
+ expect(normalizeGongPublicKeyPem(pem)).toBe(pem)
+ })
+
+ it('wraps raw base64', () => {
+ const raw = 'MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfj3'
+ const out = normalizeGongPublicKeyPem(raw)
+ expect(out).toContain('BEGIN PUBLIC KEY')
+ expect(out).toContain('END PUBLIC KEY')
+ expect(out).toContain('MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxfj3')
+ })
+
+ it('returns null for garbage', () => {
+ expect(normalizeGongPublicKeyPem('not-base64!!!')).toBeNull()
+ })
+})
+
+describe('gongHandler formatInput', () => {
+ it('always returns callId as a string', async () => {
+ const { input } = await gongHandler.formatInput!({
+ webhook: {},
+ workflow: { id: 'wf', userId: 'u' },
+ body: { callData: { metaData: {} } },
+ headers: {},
+ requestId: 'gong-format',
+ })
+
+ expect((input as Record).callId).toBe('')
+ })
+
+ it('exposes content topics and highlights alongside trackers', async () => {
+ const { input } = await gongHandler.formatInput!({
+ webhook: {},
+ workflow: { id: 'wf', userId: 'u' },
+ body: {
+ callData: {
+ metaData: { id: '99' },
+ content: {
+ trackers: [{ id: 't1', name: 'Competitor', count: 2 }],
+ topics: [{ name: 'Pricing', duration: 120 }],
+ highlights: [{ title: 'Action items' }],
+ },
+ },
+ },
+ headers: {},
+ requestId: 'gong-format-content',
+ })
+ const rec = input as Record
+ expect(rec.callId).toBe('99')
+ expect(rec.trackers).toEqual([{ id: 't1', name: 'Competitor', count: 2 }])
+ expect(rec.topics).toEqual([{ name: 'Pricing', duration: 120 }])
+ expect(rec.highlights).toEqual([{ title: 'Action items' }])
+ })
+})
+
+describe('gongHandler verifyAuth (JWT)', () => {
+ it('returns null when JWT public key is not configured', async () => {
+ const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
+ method: 'POST',
+ body: '{}',
+ })
+ const rawBody = '{}'
+ const res = await verifyGongJwtAuth({
+ webhook: {},
+ workflow: {},
+ request,
+ rawBody,
+ requestId: 't1',
+ providerConfig: {},
+ })
+ expect(res).toBeNull()
+ })
+
+ it('returns 401 when key is configured but Authorization is missing', async () => {
+ const { publicKey } = await jose.generateKeyPair('RS256')
+ const spki = await jose.exportSPKI(publicKey)
+ const request = new NextRequest('https://app.example.com/api/webhooks/trigger/abc', {
+ method: 'POST',
+ body: '{}',
+ })
+ const res = await verifyGongJwtAuth({
+ webhook: {},
+ workflow: {},
+ request,
+ rawBody: '{}',
+ requestId: 't2',
+ providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki },
+ })
+ expect(res?.status).toBe(401)
+ })
+
+ it('accepts a valid Gong-style JWT', async () => {
+ const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
+ const spki = await jose.exportSPKI(publicKey)
+ const url = 'https://app.example.com/api/webhooks/trigger/test-path'
+ const rawBody = '{"callData":{}}'
+ const bodySha = createHash('sha256').update(rawBody, 'utf8').digest('hex')
+
+ const jwt = await new jose.SignJWT({
+ webhook_url: url,
+ body_sha256: bodySha,
+ })
+ .setProtectedHeader({ alg: 'RS256' })
+ .setExpirationTime('1h')
+ .sign(privateKey)
+
+ const request = new NextRequest(url, {
+ method: 'POST',
+ body: rawBody,
+ headers: { Authorization: `Bearer ${jwt}` },
+ })
+
+ const res = await gongHandler.verifyAuth!({
+ webhook: {},
+ workflow: {},
+ request,
+ rawBody,
+ requestId: 't3',
+ providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki },
+ })
+ expect(res).toBeNull()
+ })
+
+ it('rejects JWT when body hash does not match', async () => {
+ const { publicKey, privateKey } = await jose.generateKeyPair('RS256')
+ const spki = await jose.exportSPKI(publicKey)
+ const url = 'https://app.example.com/api/webhooks/trigger/x'
+ const rawBody = '{"a":1}'
+
+ const jwt = await new jose.SignJWT({
+ webhook_url: url,
+ body_sha256: 'deadbeef',
+ })
+ .setProtectedHeader({ alg: 'RS256' })
+ .setExpirationTime('1h')
+ .sign(privateKey)
+
+ const request = new NextRequest(url, {
+ method: 'POST',
+ body: rawBody,
+ headers: { Authorization: jwt },
+ })
+
+ const res = await verifyGongJwtAuth({
+ webhook: {},
+ workflow: {},
+ request,
+ rawBody,
+ requestId: 't4',
+ providerConfig: { [GONG_JWT_PUBLIC_KEY_CONFIG_KEY]: spki },
+ })
+ expect(res?.status).toBe(401)
+ })
+})
diff --git a/apps/sim/lib/webhooks/providers/gong.ts b/apps/sim/lib/webhooks/providers/gong.ts
index c3272f5c4c1..428747cc3e8 100644
--- a/apps/sim/lib/webhooks/providers/gong.ts
+++ b/apps/sim/lib/webhooks/providers/gong.ts
@@ -1,15 +1,135 @@
+import { createHash } from 'node:crypto'
+import { createLogger } from '@sim/logger'
+import * as jose from 'jose'
+import { NextResponse } from 'next/server'
import type {
+ AuthContext,
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
+const logger = createLogger('WebhookProvider:Gong')
+
+/** providerConfig key: PEM or raw base64 RSA public key from Gong (Signed JWT header auth). */
+export const GONG_JWT_PUBLIC_KEY_CONFIG_KEY = 'gongJwtPublicKeyPem'
+
+/**
+ * Gong automation webhooks support either URL secrecy (token in path) or a signed JWT in
+ * `Authorization` (see https://help.gong.io/docs/create-a-webhook-rule).
+ * When {@link GONG_JWT_PUBLIC_KEY_CONFIG_KEY} is set, we verify RS256 per Gong's JWT guide.
+ * When unset, only the unguessable Sim webhook path authenticates the request (same as before).
+ */
+export function normalizeGongPublicKeyPem(input: string): string | null {
+ const trimmed = input.trim()
+ if (!trimmed) return null
+ if (trimmed.includes('BEGIN PUBLIC KEY')) {
+ return trimmed
+ }
+ const b64 = trimmed.replace(/\s/g, '')
+ if (!/^[A-Za-z0-9+/]+=*$/.test(b64)) {
+ return null
+ }
+ const chunked = b64.match(/.{1,64}/g)?.join('\n') ?? b64
+ return `-----BEGIN PUBLIC KEY-----\n${chunked}\n-----END PUBLIC KEY-----`
+}
+
+function normalizeUrlForGongJwtClaim(url: string): string {
+ try {
+ const u = new URL(url)
+ let path = u.pathname
+ if (path.length > 1 && path.endsWith('/')) {
+ path = path.slice(0, -1)
+ }
+ return `${u.protocol}//${u.host.toLowerCase()}${path}`
+ } catch {
+ return url.trim()
+ }
+}
+
+function parseAuthorizationJwt(authHeader: string | null): string | null {
+ if (!authHeader) return null
+ const trimmed = authHeader.trim()
+ if (trimmed.toLowerCase().startsWith('bearer ')) {
+ return trimmed.slice(7).trim() || null
+ }
+ return trimmed || null
+}
+
+export async function verifyGongJwtAuth(ctx: AuthContext): Promise {
+ const { request, rawBody, requestId, providerConfig } = ctx
+ const rawKey = providerConfig[GONG_JWT_PUBLIC_KEY_CONFIG_KEY]
+ if (typeof rawKey !== 'string') {
+ return null
+ }
+
+ const pem = normalizeGongPublicKeyPem(rawKey)
+ if (!pem) {
+ logger.warn(`[${requestId}] Gong JWT public key configured but could not be normalized`)
+ return new NextResponse('Unauthorized - Invalid Gong JWT public key configuration', {
+ status: 401,
+ })
+ }
+
+ const token = parseAuthorizationJwt(request.headers.get('authorization'))
+ if (!token) {
+ logger.warn(`[${requestId}] Gong JWT verification enabled but Authorization header missing`)
+ return new NextResponse('Unauthorized - Missing Gong JWT', { status: 401 })
+ }
+
+ let payload: jose.JWTPayload
+ try {
+ const key = await jose.importSPKI(pem, 'RS256')
+ const verified = await jose.jwtVerify(token, key, { algorithms: ['RS256'] })
+ payload = verified.payload
+ } catch (error) {
+ logger.warn(`[${requestId}] Gong JWT verification failed`, {
+ message: error instanceof Error ? error.message : String(error),
+ })
+ return new NextResponse('Unauthorized - Invalid Gong JWT', { status: 401 })
+ }
+
+ const claimUrl = payload.webhook_url
+ if (typeof claimUrl !== 'string' || !claimUrl) {
+ logger.warn(`[${requestId}] Gong JWT missing webhook_url claim`)
+ return new NextResponse('Unauthorized - Invalid Gong JWT claims', { status: 401 })
+ }
+
+ const claimDigest = payload.body_sha256
+ if (typeof claimDigest !== 'string' || !claimDigest) {
+ logger.warn(`[${requestId}] Gong JWT missing body_sha256 claim`)
+ return new NextResponse('Unauthorized - Invalid Gong JWT claims', { status: 401 })
+ }
+
+ const expectedDigest = createHash('sha256').update(rawBody, 'utf8').digest('hex')
+ if (claimDigest !== expectedDigest) {
+ logger.warn(`[${requestId}] Gong JWT body_sha256 mismatch`)
+ return new NextResponse('Unauthorized - Gong JWT body mismatch', { status: 401 })
+ }
+
+ const receivedNorm = normalizeUrlForGongJwtClaim(request.url)
+ const claimNorm = normalizeUrlForGongJwtClaim(claimUrl)
+ if (receivedNorm !== claimNorm) {
+ logger.warn(`[${requestId}] Gong JWT webhook_url mismatch`, {
+ receivedNorm,
+ claimNorm,
+ })
+ return new NextResponse('Unauthorized - Gong JWT URL mismatch', { status: 401 })
+ }
+
+ return null
+}
+
export const gongHandler: WebhookProviderHandler = {
+ verifyAuth: verifyGongJwtAuth,
+
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
+ const callId =
+ typeof metaData.id === 'string' || typeof metaData.id === 'number' ? String(metaData.id) : ''
return {
input: {
@@ -19,6 +139,10 @@ export const gongHandler: WebhookProviderHandler = {
parties: (callData?.parties as unknown[]) || [],
context: (callData?.context as unknown[]) || [],
trackers: (content?.trackers as unknown[]) || [],
+ topics: (content?.topics as unknown[]) || [],
+ highlights: (content?.highlights as unknown[]) || [],
+ eventType: 'gong.automation_rule',
+ callId,
},
}
},
diff --git a/apps/sim/lib/webhooks/providers/grain.ts b/apps/sim/lib/webhooks/providers/grain.ts
index 02bb0122076..39be11cab66 100644
--- a/apps/sim/lib/webhooks/providers/grain.ts
+++ b/apps/sim/lib/webhooks/providers/grain.ts
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { NextResponse } from 'next/server'
-import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
+import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
EventFilterContext,
diff --git a/apps/sim/lib/webhooks/providers/greenhouse.ts b/apps/sim/lib/webhooks/providers/greenhouse.ts
index 65f3090dee8..241e2221d10 100644
--- a/apps/sim/lib/webhooks/providers/greenhouse.ts
+++ b/apps/sim/lib/webhooks/providers/greenhouse.ts
@@ -1,6 +1,5 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
-import { NextResponse } from 'next/server'
import { safeCompare } from '@/lib/core/security/encryption'
import type {
EventMatchContext,
@@ -9,7 +8,6 @@ import type {
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
-import { isGreenhouseEventMatch } from '@/triggers/greenhouse/utils'
const logger = createLogger('WebhookProvider:Greenhouse')
@@ -45,36 +43,103 @@ export const greenhouseHandler: WebhookProviderHandler = {
async formatInput({ body }: FormatInputContext): Promise {
const b = body as Record
+ const payload = (b.payload || {}) as Record
+ const application = (payload.application || {}) as Record
+ const candidate = (application.candidate || {}) as Record
+ const jobNested = payload.job
+
+ let applicationId: number | null = null
+ if (typeof application.id === 'number') {
+ applicationId = application.id
+ } else if (typeof payload.application_id === 'number') {
+ applicationId = payload.application_id
+ }
+
+ const candidateId = typeof candidate.id === 'number' ? candidate.id : null
+
+ let jobId: number | null = null
+ if (
+ jobNested &&
+ typeof jobNested === 'object' &&
+ typeof (jobNested as Record).id === 'number'
+ ) {
+ jobId = (jobNested as Record).id as number
+ } else if (typeof payload.job_id === 'number') {
+ jobId = payload.job_id
+ }
+
return {
input: {
action: b.action,
+ applicationId,
+ candidateId,
+ jobId,
payload: b.payload || {},
},
}
},
- async matchEvent({ webhook, body, requestId, providerConfig }: EventMatchContext) {
+ async matchEvent({ webhook, workflow, 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') {
+ const { isGreenhouseEventMatch } = await import('@/triggers/greenhouse/utils')
if (!isGreenhouseEventMatch(triggerId, action || '')) {
logger.debug(
`[${requestId}] Greenhouse event mismatch for trigger ${triggerId}. Action: ${action}. Skipping execution.`,
{
webhookId: webhook.id,
+ workflowId: workflow.id,
triggerId,
receivedAction: action,
}
)
- return NextResponse.json({
- message: 'Event type does not match trigger configuration. Ignoring.',
- })
+ return false
}
}
return true
},
+
+ /**
+ * Fallback when Greenhouse-Event-ID is not available on headers (see idempotency service).
+ * Prefer stable resource keys; offer events include version for new versions.
+ */
+ extractIdempotencyId(body: unknown) {
+ const b = body as Record
+ const action = typeof b.action === 'string' ? b.action : ''
+ const payload = (b.payload || {}) as Record
+
+ const application = (payload.application || {}) as Record
+ const appId = application.id
+ if (appId !== undefined && appId !== null && appId !== '') {
+ return `greenhouse:${action}:application:${String(appId)}`
+ }
+
+ const offerId = payload.id
+ const offerVersion = payload.version
+ if (offerId !== undefined && offerId !== null && offerId !== '') {
+ const v = offerVersion !== undefined && offerVersion !== null ? String(offerVersion) : '0'
+ return `greenhouse:${action}:offer:${String(offerId)}:${v}`
+ }
+
+ const offer = (payload.offer || {}) as Record
+ const nestedOfferId = offer.id
+ if (nestedOfferId !== undefined && nestedOfferId !== null && nestedOfferId !== '') {
+ const nestedVersion =
+ offer.version !== undefined && offer.version !== null ? String(offer.version) : '0'
+ return `greenhouse:${action}:offer:${String(nestedOfferId)}:${nestedVersion}`
+ }
+
+ const job = (payload.job || {}) as Record
+ const jobId = job.id
+ if (jobId !== undefined && jobId !== null && jobId !== '') {
+ return `greenhouse:${action}:job:${String(jobId)}`
+ }
+
+ return null
+ },
}
diff --git a/apps/sim/lib/webhooks/providers/lemlist.ts b/apps/sim/lib/webhooks/providers/lemlist.ts
index 2127512f9d3..2215839b8de 100644
--- a/apps/sim/lib/webhooks/providers/lemlist.ts
+++ b/apps/sim/lib/webhooks/providers/lemlist.ts
@@ -1,6 +1,6 @@
import { createLogger } from '@sim/logger'
import { validateAlphanumericId } from '@/lib/core/security/input-validation'
-import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
+import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
SubscriptionContext,
diff --git a/apps/sim/lib/webhooks/providers/linear.test.ts b/apps/sim/lib/webhooks/providers/linear.test.ts
new file mode 100644
index 00000000000..0f94977f3e7
--- /dev/null
+++ b/apps/sim/lib/webhooks/providers/linear.test.ts
@@ -0,0 +1,78 @@
+import crypto from 'node:crypto'
+import { NextRequest } from 'next/server'
+import { describe, expect, it } from 'vitest'
+import { linearHandler } from '@/lib/webhooks/providers/linear'
+
+function signLinearBody(secret: string, rawBody: string): string {
+ return crypto.createHmac('sha256', secret).update(rawBody, 'utf8').digest('hex')
+}
+
+function requestWithLinearSignature(secret: string, rawBody: string): NextRequest {
+ const signature = signLinearBody(secret, rawBody)
+ return new NextRequest('http://localhost/test', {
+ headers: {
+ 'Linear-Signature': signature,
+ },
+ })
+}
+
+describe('Linear webhook provider', () => {
+ it('rejects signed requests when webhookTimestamp is missing', async () => {
+ const secret = 'linear-secret'
+ const rawBody = JSON.stringify({
+ action: 'create',
+ type: 'Issue',
+ })
+
+ const res = await linearHandler.verifyAuth!({
+ request: requestWithLinearSignature(secret, rawBody),
+ rawBody,
+ requestId: 'linear-t1',
+ providerConfig: { webhookSecret: secret },
+ webhook: {},
+ workflow: {},
+ })
+
+ expect(res?.status).toBe(401)
+ })
+
+ it('rejects signed requests when webhookTimestamp skew is too large', async () => {
+ const secret = 'linear-secret'
+ const rawBody = JSON.stringify({
+ action: 'update',
+ type: 'Issue',
+ webhookTimestamp: Date.now() - 600_000,
+ })
+
+ const res = await linearHandler.verifyAuth!({
+ request: requestWithLinearSignature(secret, rawBody),
+ rawBody,
+ requestId: 'linear-t2',
+ providerConfig: { webhookSecret: secret },
+ webhook: {},
+ workflow: {},
+ })
+
+ expect(res?.status).toBe(401)
+ })
+
+ it('accepts signed requests within the allowed timestamp window', async () => {
+ const secret = 'linear-secret'
+ const rawBody = JSON.stringify({
+ action: 'update',
+ type: 'Issue',
+ webhookTimestamp: Date.now(),
+ })
+
+ const res = await linearHandler.verifyAuth!({
+ request: requestWithLinearSignature(secret, rawBody),
+ rawBody,
+ requestId: 'linear-t3',
+ providerConfig: { webhookSecret: secret },
+ webhook: {},
+ workflow: {},
+ })
+
+ expect(res).toBeNull()
+ })
+})
diff --git a/apps/sim/lib/webhooks/providers/linear.ts b/apps/sim/lib/webhooks/providers/linear.ts
index 7df343dfc6f..97e9d79a877 100644
--- a/apps/sim/lib/webhooks/providers/linear.ts
+++ b/apps/sim/lib/webhooks/providers/linear.ts
@@ -1,9 +1,11 @@
import crypto from 'crypto'
import { createLogger } from '@sim/logger'
+import { NextResponse } from 'next/server'
import { safeCompare } from '@/lib/core/security/encryption'
import { generateId } from '@/lib/core/utils/uuid'
-import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
+import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
+ AuthContext,
DeleteSubscriptionContext,
EventMatchContext,
FormatInputContext,
@@ -12,7 +14,6 @@ import type {
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
-import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Linear')
@@ -41,16 +42,73 @@ function validateLinearSignature(secret: string, signature: string, body: string
}
}
+const LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS = 5 * 60 * 1000
+
export const linearHandler: WebhookProviderHandler = {
- verifyAuth: createHmacVerifier({
- configKey: 'webhookSecret',
- headerName: 'Linear-Signature',
- validateFn: validateLinearSignature,
- providerLabel: 'Linear',
- }),
+ async verifyAuth({
+ request,
+ rawBody,
+ requestId,
+ providerConfig,
+ }: AuthContext): Promise {
+ const secret = providerConfig.webhookSecret as string | undefined
+ if (!secret) {
+ return null
+ }
+
+ 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 })
+ }
+
+ if (!validateLinearSignature(secret, signature, rawBody)) {
+ logger.warn(`[${requestId}] Linear signature verification failed`)
+ return new NextResponse('Unauthorized - Invalid Linear signature', { status: 401 })
+ }
+
+ try {
+ const parsed = JSON.parse(rawBody) as Record
+ const ts = parsed.webhookTimestamp
+ if (typeof ts !== 'number' || !Number.isFinite(ts)) {
+ logger.warn(`[${requestId}] Linear webhookTimestamp missing or invalid`)
+ return new NextResponse('Unauthorized - Invalid webhook timestamp', {
+ status: 401,
+ })
+ }
+
+ if (Math.abs(Date.now() - ts) > LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS) {
+ logger.warn(
+ `[${requestId}] Linear webhookTimestamp outside allowed skew (${LINEAR_WEBHOOK_TIMESTAMP_SKEW_MS}ms)`
+ )
+ return new NextResponse('Unauthorized - Webhook timestamp skew too large', {
+ status: 401,
+ })
+ }
+ } catch (error) {
+ logger.warn(
+ `[${requestId}] Linear webhook body parse failed after signature verification`,
+ error
+ )
+ return new NextResponse('Unauthorized - Invalid webhook body', { status: 401 })
+ }
+
+ return null
+ },
async formatInput({ body }: FormatInputContext): Promise {
const b = body as Record
+ const rawActor = b.actor
+ let actor: unknown = null
+ if (rawActor && typeof rawActor === 'object' && !Array.isArray(rawActor)) {
+ const a = rawActor as Record
+ const { type: linearActorType, ...rest } = a
+ actor = {
+ ...rest,
+ actorType: typeof linearActorType === 'string' ? linearActorType : null,
+ }
+ }
+
return {
input: {
action: b.action || '',
@@ -59,7 +117,8 @@ export const linearHandler: WebhookProviderHandler = {
webhookTimestamp: b.webhookTimestamp || 0,
organizationId: b.organizationId || '',
createdAt: b.createdAt || '',
- actor: b.actor || null,
+ url: typeof b.url === 'string' ? b.url : '',
+ actor,
data: b.data || null,
updatedFrom: b.updatedFrom || null,
},
@@ -160,6 +219,12 @@ export const linearHandler: WebhookProviderHandler = {
}
const externalId = result.webhook?.id
+ if (typeof externalId !== 'string' || !externalId.trim()) {
+ throw new Error(
+ 'Linear webhook was created but the API response did not include a webhook id.'
+ )
+ }
+
logger.info(
`[${ctx.requestId}] Created Linear webhook ${externalId} for webhook ${ctx.webhook.id}`
)
@@ -228,13 +293,4 @@ export const linearHandler: WebhookProviderHandler = {
})
}
},
-
- 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
index 8270eb93e01..11af3634290 100644
--- a/apps/sim/lib/webhooks/providers/microsoft-teams.ts
+++ b/apps/sim/lib/webhooks/providers/microsoft-teams.ts
@@ -15,7 +15,7 @@ import {
getCredentialOwner,
getNotificationUrl,
getProviderConfig,
-} from '@/lib/webhooks/providers/subscription-utils'
+} from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
diff --git a/apps/sim/lib/webhooks/providers/notion.test.ts b/apps/sim/lib/webhooks/providers/notion.test.ts
new file mode 100644
index 00000000000..2a16911373a
--- /dev/null
+++ b/apps/sim/lib/webhooks/providers/notion.test.ts
@@ -0,0 +1,28 @@
+import { describe, expect, it } from 'vitest'
+import { notionHandler } from '@/lib/webhooks/providers/notion'
+import { isNotionPayloadMatch } from '@/triggers/notion/utils'
+
+describe('Notion webhook provider', () => {
+ it('matches both legacy and newer schema updated event names', () => {
+ expect(
+ isNotionPayloadMatch('notion_database_schema_updated', {
+ type: 'database.schema_updated',
+ })
+ ).toBe(true)
+
+ expect(
+ isNotionPayloadMatch('notion_database_schema_updated', {
+ type: 'data_source.schema_updated',
+ })
+ ).toBe(true)
+ })
+
+ it('builds a stable idempotency key from event type and id', () => {
+ const key = notionHandler.extractIdempotencyId!({
+ id: 'evt_123',
+ type: 'page.created',
+ })
+
+ expect(key).toBe('notion:page.created:evt_123')
+ })
+})
diff --git a/apps/sim/lib/webhooks/providers/notion.ts b/apps/sim/lib/webhooks/providers/notion.ts
index 8155fc67084..ed3f1b1c965 100644
--- a/apps/sim/lib/webhooks/providers/notion.ts
+++ b/apps/sim/lib/webhooks/providers/notion.ts
@@ -53,21 +53,61 @@ export const notionHandler: WebhookProviderHandler = {
providerLabel: 'Notion',
}),
+ handleReachabilityTest(body: unknown, requestId: string) {
+ const obj = body as Record | null
+ const verificationToken = obj?.verification_token
+
+ if (typeof verificationToken === 'string' && verificationToken.length > 0) {
+ logger.info(`[${requestId}] Notion verification request detected - returning 200`)
+ return NextResponse.json({
+ status: 'ok',
+ message: 'Webhook endpoint verified',
+ })
+ }
+
+ return null
+ },
+
async formatInput({ body }: FormatInputContext): Promise {
const b = body as Record
+ const rawEntity =
+ b.entity && typeof b.entity === 'object' ? (b.entity as Record) : {}
+ const rawData = b.data && typeof b.data === 'object' ? (b.data as Record) : {}
+ const rawParent =
+ rawData.parent && typeof rawData.parent === 'object'
+ ? (rawData.parent as Record)
+ : null
+ const { type: entityType, ...entityRest } = rawEntity
+ const { type: _rawParentType, ...parentRest } = rawParent ?? {}
+
return {
input: {
id: b.id,
type: b.type,
timestamp: b.timestamp,
+ api_version: b.api_version,
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 || {},
+ accessible_by: b.accessible_by || [],
+ entity: {
+ ...entityRest,
+ entity_type: entityType,
+ },
+ data: {
+ ...rawData,
+ ...(rawParent
+ ? {
+ parent: {
+ ...parentRest,
+ parent_type: rawParent.type,
+ },
+ }
+ : {}),
+ },
},
}
},
@@ -89,12 +129,23 @@ export const notionHandler: WebhookProviderHandler = {
receivedEvent: eventType,
}
)
- return NextResponse.json({
- message: 'Event type does not match trigger configuration. Ignoring.',
- })
+ return false
}
}
return true
},
+
+ extractIdempotencyId(body: unknown) {
+ const obj = body as Record
+ const id = obj.id
+ const type = obj.type
+ if (
+ (typeof id === 'string' || typeof id === 'number') &&
+ (typeof type === 'string' || typeof type === 'number')
+ ) {
+ return `notion:${String(type)}:${String(id)}`
+ }
+ return null
+ },
}
diff --git a/apps/sim/lib/webhooks/providers/registry.ts b/apps/sim/lib/webhooks/providers/registry.ts
index 9de1628f0c9..789546a755b 100644
--- a/apps/sim/lib/webhooks/providers/registry.ts
+++ b/apps/sim/lib/webhooks/providers/registry.ts
@@ -27,6 +27,7 @@ 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'
+import { salesforceHandler } from '@/lib/webhooks/providers/salesforce'
import { slackHandler } from '@/lib/webhooks/providers/slack'
import { stripeHandler } from '@/lib/webhooks/providers/stripe'
import { telegramHandler } from '@/lib/webhooks/providers/telegram'
@@ -70,6 +71,7 @@ const PROVIDER_HANDLERS: Record = {
notion: notionHandler,
outlook: outlookHandler,
rss: rssHandler,
+ salesforce: salesforceHandler,
slack: slackHandler,
stripe: stripeHandler,
telegram: telegramHandler,
diff --git a/apps/sim/lib/webhooks/providers/resend.test.ts b/apps/sim/lib/webhooks/providers/resend.test.ts
new file mode 100644
index 00000000000..9919ff22930
--- /dev/null
+++ b/apps/sim/lib/webhooks/providers/resend.test.ts
@@ -0,0 +1,45 @@
+import { describe, expect, it } from 'vitest'
+import { resendHandler } from '@/lib/webhooks/providers/resend'
+
+describe('Resend webhook provider', () => {
+ it('formatInput exposes documented email metadata and distinct data.created_at', async () => {
+ const { input } = await resendHandler.formatInput!({
+ webhook: {},
+ workflow: { id: 'wf', userId: 'u' },
+ body: {
+ type: 'email.bounced',
+ created_at: '2024-11-22T23:41:12.126Z',
+ data: {
+ broadcast_id: '8b146471-e88e-4322-86af-016cd36fd216',
+ created_at: '2024-11-22T23:41:11.894719+00:00',
+ email_id: '56761188-7520-42d8-8898-ff6fc54ce618',
+ from: 'Acme ',
+ to: ['delivered@resend.dev'],
+ subject: 'Sending this example',
+ template_id: '43f68331-0622-4e15-8202-246a0388854b',
+ tags: { category: 'confirm_email' },
+ bounce: {
+ message: 'Hard bounce',
+ subType: 'Suppressed',
+ type: 'Permanent',
+ },
+ },
+ },
+ headers: {},
+ requestId: 'test',
+ })
+
+ expect(input).toMatchObject({
+ type: 'email.bounced',
+ created_at: '2024-11-22T23:41:12.126Z',
+ data_created_at: '2024-11-22T23:41:11.894719+00:00',
+ email_id: '56761188-7520-42d8-8898-ff6fc54ce618',
+ broadcast_id: '8b146471-e88e-4322-86af-016cd36fd216',
+ template_id: '43f68331-0622-4e15-8202-246a0388854b',
+ tags: { category: 'confirm_email' },
+ bounceType: 'Permanent',
+ bounceSubType: 'Suppressed',
+ bounceMessage: 'Hard bounce',
+ })
+ })
+})
diff --git a/apps/sim/lib/webhooks/providers/resend.ts b/apps/sim/lib/webhooks/providers/resend.ts
index 82d452ba8cd..2238863525f 100644
--- a/apps/sim/lib/webhooks/providers/resend.ts
+++ b/apps/sim/lib/webhooks/providers/resend.ts
@@ -2,7 +2,7 @@ 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 { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
@@ -13,29 +13,13 @@ import type {
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
+import {
+ RESEND_ALL_WEBHOOK_EVENT_TYPES,
+ RESEND_TRIGGER_TO_EVENT_TYPE,
+} from '@/triggers/resend/utils'
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}`
@@ -86,8 +70,9 @@ export const resendHandler: WebhookProviderHandler = {
providerConfig,
}: AuthContext): Promise {
const signingSecret = providerConfig.signingSecret as string | undefined
- if (!signingSecret) {
- return null
+ if (!signingSecret?.trim()) {
+ logger.warn(`[${requestId}] Resend webhook missing signing secret in provider configuration`)
+ return new NextResponse('Unauthorized - Resend signing secret is required', { status: 401 })
}
const svixId = request.headers.get('svix-id')
@@ -113,20 +98,15 @@ export const resendHandler: WebhookProviderHandler = {
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 = RESEND_TRIGGER_TO_EVENT_TYPE[triggerId]
+ if (!expectedType) {
+ logger.debug(`[${requestId}] Unknown Resend triggerId ${triggerId}, skipping.`)
+ return false
}
- const expectedType = EVENT_TYPE_MAP[triggerId]
const actualType = (body as Record)?.type as string | undefined
- if (expectedType && actualType !== expectedType) {
+ if (actualType !== expectedType) {
logger.debug(
`[${requestId}] Resend event type mismatch: expected ${expectedType}, got ${actualType}. Skipping.`
)
@@ -141,12 +121,24 @@ export const resendHandler: WebhookProviderHandler = {
const data = payload.data as Record | undefined
const bounce = data?.bounce as Record | undefined
const click = data?.click as Record | undefined
+ const dataCreatedAt = data?.created_at
+ const dataCreatedAtStr =
+ typeof dataCreatedAt === 'string'
+ ? dataCreatedAt
+ : dataCreatedAt != null
+ ? String(dataCreatedAt)
+ : null
return {
input: {
type: payload.type,
created_at: payload.created_at,
+ data_created_at: dataCreatedAtStr,
+ data: data ?? null,
email_id: data?.email_id ?? null,
+ broadcast_id: data?.broadcast_id ?? null,
+ template_id: data?.template_id ?? null,
+ tags: data?.tags ?? null,
from: data?.from ?? null,
to: data?.to ?? null,
subject: data?.subject ?? null,
@@ -177,18 +169,17 @@ export const resendHandler: WebhookProviderHandler = {
)
}
- 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 =
+ triggerId === 'resend_webhook'
+ ? RESEND_ALL_WEBHOOK_EVENT_TYPES
+ : triggerId && RESEND_TRIGGER_TO_EVENT_TYPE[triggerId]
+ ? [RESEND_TRIGGER_TO_EVENT_TYPE[triggerId]]
+ : null
+
+ if (!events?.length) {
+ throw new Error(`Unknown or unsupported Resend trigger type: ${triggerId ?? '(missing)'}`)
}
- const events = eventTypeMap[triggerId ?? ''] ?? ALL_RESEND_EVENTS
const notificationUrl = getNotificationUrl(webhook)
logger.info(`[${requestId}] Creating Resend webhook`, {
@@ -231,17 +222,31 @@ export const resendHandler: WebhookProviderHandler = {
throw new Error(userFriendlyMessage)
}
+ const externalId = responseBody.id
+ const signingSecretOut = responseBody.signing_secret
+
+ if (typeof externalId !== 'string' || !externalId.trim()) {
+ throw new Error(
+ 'Resend webhook was created but the API response did not include a webhook id.'
+ )
+ }
+ if (typeof signingSecretOut !== 'string' || !signingSecretOut.trim()) {
+ throw new Error(
+ 'Resend webhook was created but the API response did not include a signing secret.'
+ )
+ }
+
logger.info(
`[${requestId}] Successfully created webhook in Resend for webhook ${webhook.id}.`,
{
- resendWebhookId: responseBody.id,
+ resendWebhookId: externalId,
}
)
return {
providerConfigUpdates: {
- externalId: responseBody.id,
- signingSecret: responseBody.signing_secret,
+ externalId,
+ signingSecret: signingSecretOut,
},
}
} catch (error: unknown) {
diff --git a/apps/sim/lib/webhooks/providers/salesforce.test.ts b/apps/sim/lib/webhooks/providers/salesforce.test.ts
new file mode 100644
index 00000000000..5865fdcb8e0
--- /dev/null
+++ b/apps/sim/lib/webhooks/providers/salesforce.test.ts
@@ -0,0 +1,127 @@
+import { NextRequest } from 'next/server'
+import { describe, expect, it } from 'vitest'
+import { salesforceHandler } from '@/lib/webhooks/providers/salesforce'
+import { isSalesforceEventMatch } from '@/triggers/salesforce/utils'
+
+function reqWithHeaders(headers: Record): NextRequest {
+ return new NextRequest('http://localhost/test', { headers })
+}
+
+describe('Salesforce webhook provider', () => {
+ it('verifyAuth rejects when webhookSecret is missing', async () => {
+ const res = await salesforceHandler.verifyAuth!({
+ request: reqWithHeaders({}),
+ rawBody: '{}',
+ requestId: 't1',
+ providerConfig: {},
+ webhook: {},
+ workflow: {},
+ })
+ expect(res?.status).toBe(401)
+ })
+
+ it('verifyAuth accepts Authorization Bearer secret', async () => {
+ const res = await salesforceHandler.verifyAuth!({
+ request: reqWithHeaders({ authorization: 'Bearer my-secret-value' }),
+ rawBody: '{}',
+ requestId: 't2',
+ providerConfig: { webhookSecret: 'my-secret-value' },
+ webhook: {},
+ workflow: {},
+ })
+ expect(res).toBeNull()
+ })
+
+ it('verifyAuth accepts X-Sim-Webhook-Secret', async () => {
+ const res = await salesforceHandler.verifyAuth!({
+ request: reqWithHeaders({ 'x-sim-webhook-secret': 'abc' }),
+ rawBody: '{}',
+ requestId: 't3',
+ providerConfig: { webhookSecret: 'abc' },
+ webhook: {},
+ workflow: {},
+ })
+ expect(res).toBeNull()
+ })
+
+ it('isSalesforceEventMatch filters record triggers by eventType', () => {
+ expect(
+ isSalesforceEventMatch('salesforce_record_created', { eventType: 'created' }, undefined)
+ ).toBe(true)
+ expect(
+ isSalesforceEventMatch('salesforce_record_created', { eventType: 'updated' }, undefined)
+ ).toBe(false)
+ expect(isSalesforceEventMatch('salesforce_record_created', {}, undefined)).toBe(false)
+ })
+
+ it('isSalesforceEventMatch enforces objectType config for generic webhook', () => {
+ expect(
+ isSalesforceEventMatch('salesforce_webhook', { objectType: 'Account', Id: 'x' }, 'Account')
+ ).toBe(true)
+ expect(
+ isSalesforceEventMatch('salesforce_webhook', { objectType: 'Contact', Id: 'x' }, 'Account')
+ ).toBe(false)
+ expect(isSalesforceEventMatch('salesforce_webhook', { Id: 'x' }, 'Account')).toBe(false)
+ })
+
+ it('isSalesforceEventMatch fails closed for record triggers when configured objectType is missing', () => {
+ expect(
+ isSalesforceEventMatch(
+ 'salesforce_record_created',
+ { eventType: 'created', Id: '001' },
+ 'Account'
+ )
+ ).toBe(false)
+ })
+
+ it('formatInput maps record trigger fields', async () => {
+ const { input } = await salesforceHandler.formatInput!({
+ body: {
+ eventType: 'created',
+ simEventType: 'after_insert',
+ objectType: 'Lead',
+ Id: '00Q1',
+ Name: 'Test',
+ OwnerId: '005OWNER',
+ SystemModstamp: '2024-01-01T00:00:00.000Z',
+ },
+ headers: {},
+ requestId: 't4',
+ webhook: { providerConfig: { triggerId: 'salesforce_record_created' } },
+ workflow: { id: 'w', userId: 'u' },
+ })
+ const i = input as Record
+ expect(i.eventType).toBe('created')
+ expect(i.simEventType).toBe('after_insert')
+ expect(i.objectType).toBe('Lead')
+ expect(i.recordId).toBe('00Q1')
+ const rec = i.record as Record
+ expect(rec.OwnerId).toBe('005OWNER')
+ expect(rec.SystemModstamp).toBe('2024-01-01T00:00:00.000Z')
+ })
+
+ it('extractIdempotencyId includes record id', () => {
+ const id = salesforceHandler.extractIdempotencyId!({
+ eventType: 'created',
+ Id: '001',
+ })
+ expect(id).toContain('001')
+ })
+
+ it('extractIdempotencyId is stable without timestamps for identical payloads', () => {
+ const body = {
+ eventType: 'updated',
+ objectType: 'Account',
+ Id: '001',
+ Name: 'Acme',
+ changedFields: ['Name'],
+ }
+
+ const first = salesforceHandler.extractIdempotencyId!(body)
+ const second = salesforceHandler.extractIdempotencyId!({ ...body })
+
+ expect(first).toBe(second)
+ expect(first).toContain('001')
+ expect(first).toContain('updated')
+ })
+})
diff --git a/apps/sim/lib/webhooks/providers/salesforce.ts b/apps/sim/lib/webhooks/providers/salesforce.ts
new file mode 100644
index 00000000000..ddd4c7f96c2
--- /dev/null
+++ b/apps/sim/lib/webhooks/providers/salesforce.ts
@@ -0,0 +1,340 @@
+import crypto from 'crypto'
+import { createLogger } from '@sim/logger'
+import { NextResponse } from 'next/server'
+import type {
+ AuthContext,
+ EventMatchContext,
+ FormatInputContext,
+ FormatInputResult,
+ WebhookProviderHandler,
+} from '@/lib/webhooks/providers/types'
+import { verifyTokenAuth } from '@/lib/webhooks/providers/utils'
+
+export function extractSalesforceObjectTypeFromPayload(
+ body: Record
+): string | undefined {
+ const direct =
+ (typeof body.objectType === 'string' && body.objectType) ||
+ (typeof body.sobjectType === 'string' && body.sobjectType) ||
+ undefined
+ if (direct) {
+ return direct
+ }
+
+ const attrs = body.attributes as Record | undefined
+ if (typeof attrs?.type === 'string') {
+ return attrs.type
+ }
+
+ const record = body.record
+ if (record && typeof record === 'object' && !Array.isArray(record)) {
+ const r = record as Record
+ if (typeof r.sobjectType === 'string') {
+ return r.sobjectType
+ }
+ const ra = r.attributes as Record | undefined
+ if (typeof ra?.type === 'string') {
+ return ra.type
+ }
+ }
+
+ return undefined
+}
+
+const logger = createLogger('WebhookProvider:Salesforce')
+
+function verifySalesforceSharedSecret(request: Request, secret: string): boolean {
+ if (verifyTokenAuth(request, secret, 'x-sim-webhook-secret')) {
+ return true
+ }
+ return verifyTokenAuth(request, secret)
+}
+
+function asRecord(body: unknown): Record {
+ return body && typeof body === 'object' && !Array.isArray(body)
+ ? (body as Record)
+ : {}
+}
+
+function extractRecordCore(body: Record): Record {
+ const nested = body.record
+ if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
+ return { ...(nested as Record) }
+ }
+
+ const skip = new Set([
+ 'eventType',
+ 'simEventType',
+ 'changedFields',
+ 'previousStage',
+ 'newStage',
+ 'previousStatus',
+ 'newStatus',
+ 'payload',
+ 'record',
+ ])
+ const out: Record = {}
+ for (const [k, v] of Object.entries(body)) {
+ if (!skip.has(k)) {
+ out[k] = v
+ }
+ }
+ return out
+}
+
+function pickTimestamp(body: Record, record: Record): string {
+ const candidates = [
+ body.timestamp,
+ body.time,
+ record.SystemModstamp,
+ record.LastModifiedDate,
+ record.CreatedDate,
+ ]
+ for (const c of candidates) {
+ if (typeof c === 'string' && c.length > 0) {
+ return c
+ }
+ }
+ return ''
+}
+
+function stableSerialize(value: unknown): string {
+ if (Array.isArray(value)) {
+ return `[${value.map((item) => stableSerialize(item)).join(',')}]`
+ }
+
+ if (value && typeof value === 'object') {
+ return `{${Object.entries(value as Record)
+ .sort(([a], [b]) => a.localeCompare(b))
+ .map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
+ .join(',')}}`
+ }
+
+ return JSON.stringify(value)
+}
+
+function buildFallbackDeliveryFingerprint(body: Record): string {
+ return crypto.createHash('sha256').update(stableSerialize(body), 'utf8').digest('hex')
+}
+
+function pickRecordId(body: Record, record: Record): string {
+ const id =
+ (typeof body.recordId === 'string' && body.recordId) ||
+ (typeof record.Id === 'string' && record.Id) ||
+ (typeof body.Id === 'string' && body.Id) ||
+ ''
+ return id
+}
+
+function pickStr(record: Record, key: string): string {
+ const v = record[key]
+ return typeof v === 'string' ? v : ''
+}
+
+export const salesforceHandler: WebhookProviderHandler = {
+ verifyAuth({ request, requestId, providerConfig }: AuthContext): NextResponse | null {
+ const secret = providerConfig.webhookSecret as string | undefined
+ if (!secret?.trim()) {
+ logger.warn(`[${requestId}] Salesforce webhook missing webhookSecret — rejecting`)
+ return new NextResponse('Unauthorized - Webhook secret not configured', { status: 401 })
+ }
+
+ if (!verifySalesforceSharedSecret(request, secret.trim())) {
+ logger.warn(`[${requestId}] Salesforce webhook secret verification failed`)
+ return new NextResponse('Unauthorized - Invalid webhook secret', { status: 401 })
+ }
+
+ return null
+ },
+
+ async matchEvent({ webhook, workflow, body, requestId, providerConfig }: EventMatchContext) {
+ const triggerId = providerConfig.triggerId as string | undefined
+ if (!triggerId) {
+ return true
+ }
+
+ const { isSalesforceEventMatch } = await import('@/triggers/salesforce/utils')
+ const configuredObjectType = providerConfig.objectType as string | undefined
+ const obj = asRecord(body)
+
+ if (!isSalesforceEventMatch(triggerId, obj, configuredObjectType)) {
+ logger.debug(
+ `[${requestId}] Salesforce event mismatch for trigger ${triggerId}. Skipping execution.`,
+ { webhookId: webhook.id, workflowId: workflow.id, triggerId }
+ )
+ return false
+ }
+
+ return true
+ },
+
+ async formatInput(ctx: FormatInputContext): Promise {
+ const rawPc = (ctx.webhook as { providerConfig?: unknown }).providerConfig
+ const pc =
+ rawPc && typeof rawPc === 'object' && !Array.isArray(rawPc)
+ ? (rawPc as Record)
+ : {}
+ const id = typeof pc.triggerId === 'string' ? pc.triggerId : ''
+ const body = asRecord(ctx.body)
+
+ const record = extractRecordCore(body)
+ const objectType =
+ extractSalesforceObjectTypeFromPayload(body) ||
+ (typeof record.attributes === 'object' &&
+ record.attributes &&
+ typeof (record.attributes as Record).type === 'string'
+ ? String((record.attributes as Record).type)
+ : '') ||
+ (typeof record.sobjectType === 'string' ? record.sobjectType : '')
+ const recordId = pickRecordId(body, record)
+ const timestamp = pickTimestamp(body, record)
+ const eventTypeRaw =
+ (typeof body.eventType === 'string' && body.eventType) ||
+ (typeof body.simEventType === 'string' && body.simEventType) ||
+ ''
+ const simEventTypeRaw = typeof body.simEventType === 'string' ? body.simEventType : ''
+
+ if (id === 'salesforce_webhook') {
+ return {
+ input: {
+ eventType: eventTypeRaw || 'webhook',
+ objectType: objectType || '',
+ recordId,
+ timestamp,
+ simEventType: simEventTypeRaw,
+ record: Object.keys(record).length > 0 ? record : body,
+ payload: ctx.body,
+ },
+ }
+ }
+
+ if (
+ id === 'salesforce_record_created' ||
+ id === 'salesforce_record_updated' ||
+ id === 'salesforce_record_deleted'
+ ) {
+ const changedFields = body.changedFields
+ return {
+ input: {
+ eventType: eventTypeRaw || id.replace('salesforce_', '').replace(/_/g, ' '),
+ objectType: objectType || '',
+ recordId,
+ timestamp,
+ simEventType: simEventTypeRaw,
+ record: {
+ Id: typeof record.Id === 'string' ? record.Id : recordId,
+ Name: typeof record.Name === 'string' ? record.Name : '',
+ CreatedDate: typeof record.CreatedDate === 'string' ? record.CreatedDate : '',
+ LastModifiedDate:
+ typeof record.LastModifiedDate === 'string' ? record.LastModifiedDate : '',
+ OwnerId: pickStr(record, 'OwnerId'),
+ SystemModstamp: pickStr(record, 'SystemModstamp'),
+ },
+ changedFields: changedFields !== undefined ? changedFields : null,
+ payload: ctx.body,
+ },
+ }
+ }
+
+ if (id === 'salesforce_opportunity_stage_changed') {
+ return {
+ input: {
+ eventType: eventTypeRaw || 'opportunity_stage_changed',
+ objectType: objectType || 'Opportunity',
+ recordId,
+ timestamp,
+ simEventType: simEventTypeRaw,
+ record: {
+ Id: typeof record.Id === 'string' ? record.Id : recordId,
+ Name: typeof record.Name === 'string' ? record.Name : '',
+ StageName: typeof record.StageName === 'string' ? record.StageName : '',
+ Amount: record.Amount !== undefined ? String(record.Amount) : '',
+ CloseDate: typeof record.CloseDate === 'string' ? record.CloseDate : '',
+ Probability: record.Probability !== undefined ? String(record.Probability) : '',
+ AccountId: pickStr(record, 'AccountId'),
+ OwnerId: pickStr(record, 'OwnerId'),
+ },
+ previousStage:
+ typeof body.previousStage === 'string'
+ ? body.previousStage
+ : typeof body.PriorStage === 'string'
+ ? body.PriorStage
+ : '',
+ newStage:
+ typeof body.newStage === 'string'
+ ? body.newStage
+ : typeof record.StageName === 'string'
+ ? record.StageName
+ : '',
+ payload: ctx.body,
+ },
+ }
+ }
+
+ if (id === 'salesforce_case_status_changed') {
+ return {
+ input: {
+ eventType: eventTypeRaw || 'case_status_changed',
+ objectType: objectType || 'Case',
+ recordId,
+ timestamp,
+ simEventType: simEventTypeRaw,
+ record: {
+ Id: typeof record.Id === 'string' ? record.Id : recordId,
+ Subject: typeof record.Subject === 'string' ? record.Subject : '',
+ Status: typeof record.Status === 'string' ? record.Status : '',
+ Priority: typeof record.Priority === 'string' ? record.Priority : '',
+ CaseNumber: typeof record.CaseNumber === 'string' ? record.CaseNumber : '',
+ AccountId: pickStr(record, 'AccountId'),
+ ContactId: pickStr(record, 'ContactId'),
+ OwnerId: pickStr(record, 'OwnerId'),
+ },
+ previousStatus:
+ typeof body.previousStatus === 'string'
+ ? body.previousStatus
+ : typeof body.PriorStatus === 'string'
+ ? body.PriorStatus
+ : '',
+ newStatus:
+ typeof body.newStatus === 'string'
+ ? body.newStatus
+ : typeof record.Status === 'string'
+ ? record.Status
+ : '',
+ payload: ctx.body,
+ },
+ }
+ }
+
+ return {
+ input: {
+ eventType: eventTypeRaw || 'webhook',
+ objectType: objectType || '',
+ recordId,
+ timestamp,
+ simEventType: simEventTypeRaw,
+ record: Object.keys(record).length > 0 ? record : body,
+ payload: ctx.body,
+ },
+ }
+ },
+
+ extractIdempotencyId(body: unknown): string | null {
+ const b = asRecord(body)
+ const record = extractRecordCore(b)
+ const id = pickRecordId(b, record)
+ const et =
+ (typeof b.eventType === 'string' && b.eventType) ||
+ (typeof b.simEventType === 'string' && b.simEventType) ||
+ ''
+ const ts = pickTimestamp(b, record)
+ if (!id) {
+ return null
+ }
+ if (ts) {
+ return `salesforce:${et || 'event'}:${id}:${ts}`
+ }
+
+ return `salesforce:${et || 'event'}:${id}:${buildFallbackDeliveryFingerprint(b)}`
+ },
+}
diff --git a/apps/sim/lib/webhooks/providers/telegram.ts b/apps/sim/lib/webhooks/providers/telegram.ts
index 8511f9b1198..0bb2fd427f0 100644
--- a/apps/sim/lib/webhooks/providers/telegram.ts
+++ b/apps/sim/lib/webhooks/providers/telegram.ts
@@ -1,5 +1,5 @@
import { createLogger } from '@sim/logger'
-import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/providers/subscription-utils'
+import { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
diff --git a/apps/sim/lib/webhooks/providers/typeform.ts b/apps/sim/lib/webhooks/providers/typeform.ts
index 068c72d9cd4..8c96d05907f 100644
--- a/apps/sim/lib/webhooks/providers/typeform.ts
+++ b/apps/sim/lib/webhooks/providers/typeform.ts
@@ -1,7 +1,7 @@
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 { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
FormatInputContext,
diff --git a/apps/sim/lib/webhooks/providers/types.ts b/apps/sim/lib/webhooks/providers/types.ts
index 34587ce7a42..dee3e8aca19 100644
--- a/apps/sim/lib/webhooks/providers/types.ts
+++ b/apps/sim/lib/webhooks/providers/types.ts
@@ -115,10 +115,10 @@ export interface WebhookProviderHandler {
/** Custom error response when queuing fails. Return null for default 500. */
formatQueueErrorResponse?(): NextResponse | null
- /** Custom input preparation. Replaces the standard `formatWebhookInput` call when defined. */
+ /** Custom input preparation. When defined, replaces the default pass-through of the raw body. */
formatInput?(ctx: FormatInputContext): Promise
- /** Called when standard `formatWebhookInput` returns null. Return skip message or null to proceed. */
+ /** Called when input is null after formatting. Return skip message or null to proceed. */
handleEmptyInput?(requestId: string): { message: string } | null
/** Post-process input to handle file uploads before execution. */
@@ -138,6 +138,8 @@ export interface WebhookProviderHandler {
body: unknown,
request: NextRequest,
requestId: string,
- path: string
+ path: string,
+ /** Raw request body bytes (when available); required for signature checks that must match the provider (e.g. Zoom). */
+ rawBody?: string
): Promise | NextResponse | null
}
diff --git a/apps/sim/lib/webhooks/providers/vercel.test.ts b/apps/sim/lib/webhooks/providers/vercel.test.ts
new file mode 100644
index 00000000000..0792b26c45e
--- /dev/null
+++ b/apps/sim/lib/webhooks/providers/vercel.test.ts
@@ -0,0 +1,74 @@
+/**
+ * @vitest-environment node
+ */
+import crypto from 'crypto'
+import { createMockRequest } from '@sim/testing'
+import { describe, expect, it, vi } from 'vitest'
+import { vercelHandler } from '@/lib/webhooks/providers/vercel'
+
+vi.mock('@sim/logger', () => ({
+ createLogger: vi.fn().mockReturnValue({
+ info: vi.fn(),
+ warn: vi.fn(),
+ error: vi.fn(),
+ debug: vi.fn(),
+ }),
+}))
+
+describe('vercelHandler', () => {
+ describe('verifyAuth', () => {
+ const secret = 'test-signing-secret'
+ const rawBody = JSON.stringify({ type: 'deployment.created', id: 'del_1' })
+ const signature = crypto.createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex')
+
+ it('returns 401 when webhookSecret is missing', async () => {
+ const request = createMockRequest('POST', JSON.parse(rawBody), {
+ 'x-vercel-signature': signature,
+ })
+ const res = await vercelHandler.verifyAuth!({
+ request: request as any,
+ rawBody,
+ requestId: 'r1',
+ providerConfig: {},
+ webhook: {},
+ workflow: {},
+ })
+ expect(res?.status).toBe(401)
+ })
+
+ it('returns 401 when signature header is missing', async () => {
+ const request = createMockRequest('POST', JSON.parse(rawBody), {})
+ const res = await vercelHandler.verifyAuth!({
+ request: request as any,
+ rawBody,
+ requestId: 'r1',
+ providerConfig: { webhookSecret: secret },
+ webhook: {},
+ workflow: {},
+ })
+ expect(res?.status).toBe(401)
+ })
+
+ it('returns null when signature is valid', async () => {
+ const request = createMockRequest('POST', JSON.parse(rawBody), {
+ 'x-vercel-signature': signature,
+ })
+ const res = await vercelHandler.verifyAuth!({
+ request: request as any,
+ rawBody,
+ requestId: 'r1',
+ providerConfig: { webhookSecret: secret },
+ webhook: {},
+ workflow: {},
+ })
+ expect(res).toBeNull()
+ })
+ })
+
+ describe('extractIdempotencyId', () => {
+ it('uses top-level delivery id from Vercel payload', () => {
+ expect(vercelHandler.extractIdempotencyId!({ id: 'abc123' })).toBe('vercel:abc123')
+ expect(vercelHandler.extractIdempotencyId!({})).toBeNull()
+ })
+ })
+})
diff --git a/apps/sim/lib/webhooks/providers/vercel.ts b/apps/sim/lib/webhooks/providers/vercel.ts
index 7f8a0f5eccc..218afb3d6fd 100644
--- a/apps/sim/lib/webhooks/providers/vercel.ts
+++ b/apps/sim/lib/webhooks/providers/vercel.ts
@@ -1,29 +1,79 @@
import crypto from '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 { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
+ AuthContext,
DeleteSubscriptionContext,
+ EventMatchContext,
FormatInputContext,
FormatInputResult,
SubscriptionContext,
SubscriptionResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
-import { createHmacVerifier } from '@/lib/webhooks/providers/utils'
const logger = createLogger('WebhookProvider:Vercel')
+function verifyVercelSignature(secret: string, signature: string, rawBody: string): boolean {
+ const hash = crypto.createHmac('sha1', secret).update(rawBody, 'utf8').digest('hex')
+ return safeCompare(hash, signature)
+}
+
export const vercelHandler: WebhookProviderHandler = {
- verifyAuth: createHmacVerifier({
- configKey: 'webhookSecret',
- headerName: 'x-vercel-signature',
- validateFn: (secret, signature, body) => {
- const hash = crypto.createHmac('sha1', secret).update(body, 'utf8').digest('hex')
- return safeCompare(hash, signature)
- },
- providerLabel: 'Vercel',
- }),
+ verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext): NextResponse | null {
+ const secret = (providerConfig.webhookSecret as string | undefined)?.trim()
+ if (!secret) {
+ logger.warn(`[${requestId}] Vercel webhook secret missing; rejecting delivery`)
+ return new NextResponse(
+ 'Unauthorized - Vercel webhook signing secret is not configured. Re-save the trigger so a webhook can be registered.',
+ { status: 401 }
+ )
+ }
+
+ const signature = request.headers.get('x-vercel-signature')
+ if (!signature) {
+ logger.warn(`[${requestId}] Vercel webhook missing x-vercel-signature header`)
+ return new NextResponse('Unauthorized - Missing Vercel signature', { status: 401 })
+ }
+
+ if (!verifyVercelSignature(secret, signature, rawBody)) {
+ logger.warn(`[${requestId}] Vercel signature verification failed`)
+ return new NextResponse('Unauthorized - Invalid Vercel 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
+ const eventType = obj.type as string | undefined
+
+ if (triggerId && triggerId !== 'vercel_webhook') {
+ const { isVercelEventMatch } = await import('@/triggers/vercel/utils')
+ if (!isVercelEventMatch(triggerId, eventType)) {
+ logger.debug(`[${requestId}] Vercel event mismatch for trigger ${triggerId}. Skipping.`, {
+ webhookId: webhook.id,
+ workflowId: workflow.id,
+ triggerId,
+ eventType,
+ })
+ return false
+ }
+ }
+
+ return true
+ },
+
+ extractIdempotencyId(body: unknown) {
+ const id = (body as Record)?.id
+ if (id === undefined || id === null || id === '') {
+ return null
+ }
+ return `vercel:${String(id)}`
+ },
async createSubscription(ctx: SubscriptionContext): Promise {
const { webhook, requestId } = ctx
@@ -40,25 +90,24 @@ export const vercelHandler: WebhookProviderHandler = {
)
}
- const eventTypeMap: Record = {
- vercel_deployment_created: ['deployment.created'],
- vercel_deployment_ready: ['deployment.ready'],
- vercel_deployment_error: ['deployment.error'],
- vercel_deployment_canceled: ['deployment.canceled'],
- vercel_project_created: ['project.created'],
- vercel_project_removed: ['project.removed'],
- vercel_domain_created: ['domain.created'],
- vercel_webhook: undefined,
- }
+ const { VERCEL_GENERIC_TRIGGER_EVENT_TYPES, VERCEL_TRIGGER_EVENT_TYPES } = await import(
+ '@/triggers/vercel/utils'
+ )
- if (triggerId && !(triggerId in eventTypeMap)) {
- logger.warn(
- `[${requestId}] Unknown triggerId for Vercel: ${triggerId}, defaulting to all events`,
- { triggerId, webhookId: webhook.id }
+ if (
+ triggerId &&
+ triggerId !== 'vercel_webhook' &&
+ !(triggerId in VERCEL_TRIGGER_EVENT_TYPES)
+ ) {
+ throw new Error(
+ `Unknown Vercel trigger "${triggerId}". Remove and re-add the Vercel trigger, then save again.`
)
}
- const events = eventTypeMap[triggerId ?? '']
+ const events =
+ triggerId && triggerId !== 'vercel_webhook'
+ ? [VERCEL_TRIGGER_EVENT_TYPES[triggerId]]
+ : undefined
const notificationUrl = getNotificationUrl(webhook)
logger.info(`[${requestId}] Creating Vercel webhook`, {
@@ -76,19 +125,7 @@ export const vercelHandler: WebhookProviderHandler = {
*/
const requestBody: Record = {
url: notificationUrl,
- events: events || [
- 'deployment.created',
- 'deployment.ready',
- 'deployment.succeeded',
- 'deployment.error',
- 'deployment.canceled',
- 'deployment.promoted',
- 'project.created',
- 'project.removed',
- 'domain.created',
- 'edge-config.created',
- 'edge-config.deleted',
- ],
+ events: events || [...VERCEL_GENERIC_TRIGGER_EVENT_TYPES],
}
if (filterProjectIds) {
@@ -147,10 +184,17 @@ export const vercelHandler: WebhookProviderHandler = {
{ vercelWebhookId: externalId }
)
+ const signingSecret = responseBody.secret as string | undefined
+ if (!signingSecret) {
+ throw new Error(
+ 'Vercel webhook was created but no signing secret was returned. Delete the webhook in Vercel and save this trigger again.'
+ )
+ }
+
return {
providerConfigUpdates: {
externalId,
- webhookSecret: (responseBody.secret as string) || '',
+ webhookSecret: signingSecret,
},
}
} catch (error: unknown) {
@@ -206,20 +250,109 @@ export const vercelHandler: WebhookProviderHandler = {
const body = ctx.body as Record
const payload = (body.payload || {}) as Record
+ const deployment = payload.deployment ?? null
+ const project = payload.project ?? null
+ const team = payload.team ?? null
+ const user = payload.user ?? null
+ const domain = payload.domain ?? null
+
+ const linksRaw = payload.links
+ let links: { deployment: string; project: string } | null = null
+ if (linksRaw && typeof linksRaw === 'object' && !Array.isArray(linksRaw)) {
+ const L = linksRaw as Record
+ const dep = L.deployment
+ const proj = L.project
+ if (typeof dep === 'string' || typeof proj === 'string') {
+ links = {
+ deployment: typeof dep === 'string' ? dep : '',
+ project: typeof proj === 'string' ? proj : '',
+ }
+ }
+ }
+
+ const regionsRaw = payload.regions
+ const regions = Array.isArray(regionsRaw) ? regionsRaw : null
+
+ let deploymentMeta: Record | null = null
+ if (deployment && typeof deployment === 'object') {
+ const meta = (deployment as Record).meta
+ if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
+ deploymentMeta = meta as Record
+ }
+ }
+
return {
input: {
- type: body.type || '',
- id: body.id || '',
- createdAt: body.createdAt || 0,
- region: body.region || null,
+ type: body.type ?? '',
+ id: body.id != null ? String(body.id) : '',
+ createdAt: (() => {
+ const v = body.createdAt
+ if (typeof v === 'number' && !Number.isNaN(v)) {
+ return v
+ }
+ if (typeof v === 'string') {
+ const parsed = Date.parse(v)
+ return Number.isNaN(parsed) ? 0 : parsed
+ }
+ const n = Number(v)
+ return Number.isNaN(n) ? 0 : n
+ })(),
+ region: body.region != null ? String(body.region) : null,
payload,
- deployment: payload.deployment || null,
- project: payload.project || null,
- team: payload.team || null,
- user: payload.user || null,
- target: payload.target || null,
- plan: payload.plan || null,
- domain: payload.domain || null,
+ links,
+ regions,
+ deployment:
+ deployment && typeof deployment === 'object'
+ ? {
+ id:
+ (deployment as Record).id != null
+ ? String((deployment as Record).id)
+ : '',
+ url: ((deployment as Record).url as string) ?? '',
+ name: ((deployment as Record).name as string) ?? '',
+ meta: deploymentMeta,
+ }
+ : null,
+ project:
+ project && typeof project === 'object'
+ ? {
+ id:
+ (project as Record).id != null
+ ? String((project as Record).id)
+ : '',
+ name: ((project as Record).name as string) ?? '',
+ }
+ : null,
+ team:
+ team && typeof team === 'object'
+ ? {
+ id:
+ (team as Record).id != null
+ ? String((team as Record).id)
+ : '',
+ }
+ : null,
+ user:
+ user && typeof user === 'object'
+ ? {
+ id:
+ (user as Record).id != null
+ ? String((user as Record).id)
+ : '',
+ }
+ : null,
+ target: payload.target != null ? String(payload.target) : null,
+ plan: payload.plan != null ? String(payload.plan) : null,
+ domain:
+ domain && typeof domain === 'object'
+ ? {
+ name: ((domain as Record).name as string) ?? '',
+ delegated:
+ typeof (domain as Record).delegated === 'boolean'
+ ? ((domain as Record).delegated as boolean)
+ : null,
+ }
+ : null,
},
}
},
diff --git a/apps/sim/lib/webhooks/providers/webflow.ts b/apps/sim/lib/webhooks/providers/webflow.ts
index 4596e8381fc..9399bcd54e3 100644
--- a/apps/sim/lib/webhooks/providers/webflow.ts
+++ b/apps/sim/lib/webhooks/providers/webflow.ts
@@ -1,7 +1,7 @@
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 { getCredentialOwner, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
EventFilterContext,
diff --git a/apps/sim/lib/webhooks/providers/zoom.test.ts b/apps/sim/lib/webhooks/providers/zoom.test.ts
new file mode 100644
index 00000000000..57ff95e8e36
--- /dev/null
+++ b/apps/sim/lib/webhooks/providers/zoom.test.ts
@@ -0,0 +1,82 @@
+import crypto from 'node:crypto'
+import { NextRequest } from 'next/server'
+import { describe, expect, it } from 'vitest'
+import { validateZoomSignature, zoomHandler } from '@/lib/webhooks/providers/zoom'
+import { isZoomEventMatch } from '@/triggers/zoom/utils'
+
+function reqWithHeaders(headers: Record): NextRequest {
+ return new NextRequest('http://localhost/test', { headers })
+}
+
+describe('Zoom webhook provider', () => {
+ it('isZoomEventMatch rejects empty event for specialized triggers', () => {
+ expect(isZoomEventMatch('zoom_meeting_started', '')).toBe(false)
+ expect(isZoomEventMatch('zoom_meeting_started', ' ')).toBe(false)
+ expect(isZoomEventMatch('zoom_meeting_started', 'meeting.started')).toBe(true)
+ expect(isZoomEventMatch('zoom_webhook', '')).toBe(true)
+ })
+
+ it('validateZoomSignature uses raw body bytes, not a re-serialized variant', () => {
+ const secret = 'test-secret'
+ const timestamp = String(Math.floor(Date.now() / 1000))
+ const rawA = '{"a":1,"b":2}'
+ const rawB = '{"b":2,"a":1}'
+ const computed = crypto.createHmac('sha256', secret).update(`v0:${timestamp}:${rawA}`)
+ const hashA = `v0=${computed.digest('hex')}`
+ expect(validateZoomSignature(secret, hashA, timestamp, rawA)).toBe(true)
+ expect(validateZoomSignature(secret, hashA, timestamp, rawB)).toBe(false)
+ })
+
+ it('extractIdempotencyId prefers meeting uuid', () => {
+ const zid = zoomHandler.extractIdempotencyId!({
+ event: 'meeting.started',
+ event_ts: 123,
+ payload: { object: { uuid: 'u1', id: 55 } },
+ })
+ expect(zid).toBe('zoom:meeting.started:123:u1')
+ })
+
+ it('extractIdempotencyId uses participant identity when available', () => {
+ const zid = zoomHandler.extractIdempotencyId!({
+ event: 'meeting.participant_joined',
+ event_ts: 123,
+ payload: {
+ object: {
+ uuid: 'meeting-uuid',
+ participant: {
+ user_id: 'participant-1',
+ },
+ },
+ },
+ })
+ expect(zid).toBe('zoom:meeting.participant_joined:123:participant-1')
+ })
+
+ it('formatInput passes through the Zoom webhook envelope', async () => {
+ const body = {
+ event: 'meeting.started',
+ event_ts: 1700000000000,
+ payload: { account_id: 'acct', object: { id: 1 } },
+ }
+ const { input } = await zoomHandler.formatInput!({
+ webhook: {},
+ workflow: { id: 'wf', userId: 'u' },
+ body,
+ headers: {},
+ requestId: 'zoom-format',
+ })
+ expect(input).toBe(body)
+ })
+
+ it('matchEvent never executes endpoint validation payloads', async () => {
+ const result = await zoomHandler.matchEvent!({
+ webhook: { id: 'w' },
+ workflow: { id: 'wf' },
+ body: { event: 'endpoint.url_validation' },
+ request: reqWithHeaders({}),
+ requestId: 't5',
+ providerConfig: { triggerId: 'zoom_webhook' },
+ })
+ expect(result).toBe(false)
+ })
+})
diff --git a/apps/sim/lib/webhooks/providers/zoom.ts b/apps/sim/lib/webhooks/providers/zoom.ts
index ed33b2bac2e..c78cf0c9386 100644
--- a/apps/sim/lib/webhooks/providers/zoom.ts
+++ b/apps/sim/lib/webhooks/providers/zoom.ts
@@ -1,13 +1,16 @@
import crypto from 'crypto'
-import { db, webhook } from '@sim/db'
+import { db, webhook, workflow } from '@sim/db'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { safeCompare } from '@/lib/core/security/encryption'
+import { resolveEnvVarsInObject } from '@/lib/webhooks/env-resolver'
import type {
AuthContext,
EventMatchContext,
+ FormatInputContext,
+ FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
@@ -18,7 +21,8 @@ const logger = createLogger('WebhookProvider:Zoom')
* Zoom sends `x-zm-signature` as `v0=` and `x-zm-request-timestamp`.
* The message to hash is `v0:{timestamp}:{rawBody}`.
*/
-function validateZoomSignature(
+/** Exported for tests — Zoom signs `v0:{timestamp}:{rawBody}`. */
+export function validateZoomSignature(
secretToken: string,
signature: string,
timestamp: string,
@@ -46,7 +50,59 @@ function validateZoomSignature(
}
}
+async function resolveZoomChallengeSecrets(
+ path: string,
+ requestId: string
+): Promise> {
+ const rows = await db
+ .select({
+ providerConfig: webhook.providerConfig,
+ userId: workflow.userId,
+ workspaceId: workflow.workspaceId,
+ })
+ .from(webhook)
+ .innerJoin(workflow, eq(webhook.workflowId, workflow.id))
+ .where(and(eq(webhook.path, path), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true)))
+
+ const resolvedRows = await Promise.all(
+ rows.map(async (row) => {
+ const rawConfig =
+ row.providerConfig &&
+ typeof row.providerConfig === 'object' &&
+ !Array.isArray(row.providerConfig)
+ ? (row.providerConfig as Record)
+ : {}
+
+ try {
+ const config = await resolveEnvVarsInObject(
+ rawConfig,
+ row.userId,
+ row.workspaceId ?? undefined
+ )
+ const secretToken = typeof config.secretToken === 'string' ? config.secretToken : ''
+ return { secretToken }
+ } catch (error) {
+ logger.warn(`[${requestId}] Failed to resolve Zoom webhook secret for challenge`, {
+ error: error instanceof Error ? error.message : String(error),
+ path,
+ })
+ return { secretToken: '' }
+ }
+ })
+ )
+
+ return resolvedRows.filter((row) => row.secretToken)
+}
+
export const zoomHandler: WebhookProviderHandler = {
+ /**
+ * Zoom delivers the standard app webhook envelope (`event`, `event_ts`, `payload`).
+ * Pass through unchanged so trigger outputs match runtime input.
+ */
+ async formatInput({ body }: FormatInputContext): Promise {
+ return { input: body }
+ },
+
verifyAuth({ request, rawBody, requestId, providerConfig }: AuthContext) {
const secretToken = providerConfig.secretToken as string | undefined
if (!secretToken) {
@@ -72,14 +128,48 @@ export const zoomHandler: WebhookProviderHandler = {
return null
},
+ extractIdempotencyId(body: unknown): string | null {
+ const obj = body as Record
+ const event = obj.event
+ const ts = obj.event_ts
+ if (typeof event !== 'string' || ts === undefined || ts === null) {
+ return null
+ }
+ const payload = obj.payload as Record | undefined
+ const inner = payload?.object as Record | undefined
+ const participant =
+ inner?.participant &&
+ typeof inner.participant === 'object' &&
+ !Array.isArray(inner.participant)
+ ? (inner.participant as Record)
+ : null
+ const participantStable =
+ (typeof participant?.user_id === 'string' && participant.user_id) ||
+ (typeof participant?.id === 'string' && participant.id) ||
+ (typeof participant?.email === 'string' && participant.email) ||
+ (typeof participant?.join_time === 'string' && participant.join_time) ||
+ (typeof participant?.leave_time === 'string' && participant.leave_time) ||
+ ''
+ const stable =
+ participantStable ||
+ (typeof inner?.uuid === 'string' && inner.uuid) ||
+ (inner?.id !== undefined && inner.id !== null ? String(inner.id) : '') ||
+ ''
+ return `zoom:${event}:${String(ts)}:${stable}`
+ },
+
async matchEvent({ webhook: wh, workflow, body, requestId, providerConfig }: EventMatchContext) {
const triggerId = providerConfig.triggerId as string | undefined
const obj = body as Record
- const event = obj.event as string | undefined
+ const event = typeof obj.event === 'string' ? obj.event : ''
+
+ if (event === 'endpoint.url_validation') {
+ return false
+ }
if (triggerId) {
const { isZoomEventMatch } = await import('@/triggers/zoom/utils')
- if (!isZoomEventMatch(triggerId, event || '')) {
+ if (!isZoomEventMatch(triggerId, event)) {
logger.debug(
`[${requestId}] Zoom event mismatch for trigger ${triggerId}. Event: ${event}. Skipping execution.`,
{
@@ -101,7 +191,13 @@ export const zoomHandler: WebhookProviderHandler = {
* Zoom sends an `endpoint.url_validation` event with a `plainToken` that must
* be hashed with the app's secret token and returned alongside the original token.
*/
- async handleChallenge(body: unknown, request: NextRequest, requestId: string, path: string) {
+ async handleChallenge(
+ body: unknown,
+ request: NextRequest,
+ requestId: string,
+ path: string,
+ rawBody?: string
+ ) {
const obj = body as Record | null
if (obj?.event !== 'endpoint.url_validation') {
return null
@@ -115,52 +211,45 @@ export const zoomHandler: WebhookProviderHandler = {
logger.info(`[${requestId}] Zoom URL validation request received for path: ${path}`)
- // Look up the webhook record to get the secret token from providerConfig
- let secretToken = ''
- try {
- const webhooks = await db
- .select()
- .from(webhook)
- .where(
- and(eq(webhook.path, path), eq(webhook.provider, 'zoom'), eq(webhook.isActive, true))
- )
- if (webhooks.length > 0) {
- const config = webhooks[0].providerConfig as Record | null
- secretToken = (config?.secretToken as string) || ''
- }
- } catch (err) {
- logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err)
- return null
- }
-
- if (!secretToken) {
- logger.warn(
- `[${requestId}] No secret token configured for Zoom URL validation on path: ${path}`
- )
- return null
- }
-
- // Verify the challenge request's signature to prevent HMAC oracle attacks
const signature = request.headers.get('x-zm-signature')
const timestamp = request.headers.get('x-zm-request-timestamp')
if (!signature || !timestamp) {
logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`)
return null
}
- const rawBody = JSON.stringify(body)
- if (!validateZoomSignature(secretToken, signature, timestamp, rawBody)) {
- logger.warn(`[${requestId}] Zoom challenge request failed signature verification`)
+
+ const bodyForSignature =
+ rawBody !== undefined && rawBody !== null ? rawBody : JSON.stringify(body)
+
+ let rows: Array<{ secretToken: string }> = []
+ try {
+ rows = await resolveZoomChallengeSecrets(path, requestId)
+ } catch (err) {
+ logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err)
return null
}
- const hashForValidate = crypto
- .createHmac('sha256', secretToken)
- .update(plainToken)
- .digest('hex')
+ for (const row of rows) {
+ const secretToken = row.secretToken
+ if (
+ secretToken &&
+ validateZoomSignature(secretToken, signature, timestamp, bodyForSignature)
+ ) {
+ const hashForValidate = crypto
+ .createHmac('sha256', secretToken)
+ .update(plainToken)
+ .digest('hex')
+
+ return NextResponse.json({
+ plainToken,
+ encryptedToken: hashForValidate,
+ })
+ }
+ }
- return NextResponse.json({
- plainToken,
- encryptedToken: hashForValidate,
- })
+ logger.warn(
+ `[${requestId}] Zoom challenge: no matching secret for path ${path} (${rows.length} webhook row(s))`
+ )
+ return null
},
}
diff --git a/apps/sim/triggers/gong/call_completed.ts b/apps/sim/triggers/gong/call_completed.ts
index b98e6bd5c22..900d09786e2 100644
--- a/apps/sim/triggers/gong/call_completed.ts
+++ b/apps/sim/triggers/gong/call_completed.ts
@@ -1,12 +1,19 @@
import { GongIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
import type { TriggerConfig } from '@/triggers/types'
-import { buildCallOutputs, gongSetupInstructions } from './utils'
+import {
+ buildCallOutputs,
+ buildGongExtraFields,
+ gongSetupInstructions,
+ gongTriggerOptions,
+} 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.
+ * Use this when the workflow is scoped to “completed call” rules; Gong still filters calls in the rule —
+ * the payload shape is the same as other call webhooks.
*/
export const gongCallCompletedTrigger: TriggerConfig = {
id: 'gong_call_completed',
@@ -16,46 +23,12 @@ export const gongCallCompletedTrigger: TriggerConfig = {
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',
- },
- },
- ],
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'gong_call_completed',
+ triggerOptions: gongTriggerOptions,
+ setupInstructions: gongSetupInstructions('Call Completed'),
+ extraFields: buildGongExtraFields('gong_call_completed'),
+ }),
outputs: buildCallOutputs(),
diff --git a/apps/sim/triggers/gong/utils.ts b/apps/sim/triggers/gong/utils.ts
index a203478ba27..f218eb86cf5 100644
--- a/apps/sim/triggers/gong/utils.ts
+++ b/apps/sim/triggers/gong/utils.ts
@@ -1,3 +1,5 @@
+import { GONG_JWT_PUBLIC_KEY_CONFIG_KEY } from '@/lib/webhooks/providers/gong'
+import type { SubBlockConfig } from '@/blocks/types'
import type { TriggerOutput } from '@/triggers/types'
/**
@@ -8,6 +10,26 @@ export const gongTriggerOptions = [
{ label: 'Call Completed', id: 'gong_call_completed' },
]
+/**
+ * Optional Gong "Signed JWT header" verification (paste the public key from Gong).
+ * When empty, security relies on the unguessable webhook URL path (Gong "URL includes key").
+ */
+export function buildGongExtraFields(triggerId: string): SubBlockConfig[] {
+ return [
+ {
+ id: GONG_JWT_PUBLIC_KEY_CONFIG_KEY,
+ title: 'Gong JWT public key (optional)',
+ type: 'long-input',
+ placeholder:
+ 'Paste the full PEM from Gong (-----BEGIN PUBLIC KEY----- …) or raw base64. Leave empty if the rule uses URL-includes-key only.',
+ description:
+ 'Required only when your Gong rule uses **Signed JWT header**. Sim verifies RS256, `webhook_url`, and `body_sha256` per Gong. If empty, only the webhook URL path authenticates the request.',
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ },
+ ]
+}
+
/**
* Generate setup instructions for a specific Gong event type
*/
@@ -17,18 +39,20 @@ export function gongSetupInstructions(eventType: string): string {
'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.`,
+ `Configure rule filters in Gong for the calls you want (e.g. ${eventType}). Gong does not send a separate event name in the JSON payload — filtering happens in the rule.`,
'Under Actions, select "Fire webhook".',
- 'Paste the Webhook URL into the destination field.',
- 'Choose an authentication method (URL includes key or Signed JWT header).',
+ 'Paste this workflow’s Webhook URL into the destination field.',
+ 'Authentication: Use either URL includes key (recommended default — Sim’s URL is already secret) or Signed JWT header. If you use Signed JWT, paste Gong’s public key into the field above so Sim can verify the Authorization token.',
'Save the rule and click "Save" above to activate your trigger.',
]
return instructions
- .map(
- (instruction, index) =>
- `${index === 0 ? instruction : `${index}. ${instruction}`}
`
- )
+ .map((instruction, index) => {
+ if (index === 0) {
+ return `${instruction}
`
+ }
+ return `${index}. ${instruction}
`
+ })
.join('')
}
@@ -38,6 +62,15 @@ export function gongSetupInstructions(eventType: string): string {
*/
export function buildCallOutputs(): Record {
return {
+ eventType: {
+ type: 'string',
+ description:
+ 'Constant identifier for automation-rule webhooks (`gong.automation_rule`). Gong does not send distinct event names in the payload.',
+ },
+ callId: {
+ type: 'string',
+ description: 'Gong call ID (same value as metaData.id when present)',
+ },
isTest: {
type: 'boolean',
description: 'Whether this is a test webhook from the Gong UI',
@@ -54,11 +87,28 @@ export function buildCallOutputs(): Record {
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)' },
+ workspaceId: { type: 'string', description: 'Gong workspace ID' },
+ direction: { type: 'string', description: 'Call direction (Inbound, Outbound, etc.)' },
+ system: { type: 'string', description: 'Communication platform used (e.g. Zoom, Teams)' },
+ scope: { type: 'string', description: 'Call scope (Internal, External, or Unknown)' },
media: { type: 'string', description: 'Media type (Video or Audio)' },
- language: { type: 'string', description: 'Call language code' },
+ language: { type: 'string', description: 'Language code (ISO-639-2B)' },
+ sdrDisposition: {
+ type: 'string',
+ description: 'SDR disposition classification (when present)',
+ },
+ clientUniqueId: {
+ type: 'string',
+ description: 'Call identifier from the origin recording system (when present)',
+ },
+ customData: {
+ type: 'string',
+ description: 'Custom metadata from call creation (when present)',
+ },
+ purpose: { type: 'string', description: 'Call purpose (when present)' },
+ meetingUrl: { type: 'string', description: 'Web conference provider URL (when present)' },
+ isPrivate: { type: 'boolean', description: 'Whether the call is private (when present)' },
+ calendarEventId: { type: 'string', description: 'Calendar event identifier (when present)' },
},
parties: {
type: 'array',
@@ -70,7 +120,16 @@ export function buildCallOutputs(): Record {
},
trackers: {
type: 'array',
- description: 'Array of tracked topics/keywords with counts',
+ description:
+ 'Keyword and smart trackers from call content (same shape as Gong extensive-calls `content.trackers`)',
+ },
+ topics: {
+ type: 'array',
+ description: 'Topic segments with durations from call content (`content.topics`)',
+ },
+ highlights: {
+ type: 'array',
+ description: 'AI-generated highlights from call content (`content.highlights`)',
},
} as Record
}
diff --git a/apps/sim/triggers/gong/webhook.ts b/apps/sim/triggers/gong/webhook.ts
index 164a81f2d23..2e59fd0d2df 100644
--- a/apps/sim/triggers/gong/webhook.ts
+++ b/apps/sim/triggers/gong/webhook.ts
@@ -1,6 +1,12 @@
import { GongIcon } from '@/components/icons'
+import { buildTriggerSubBlocks } from '@/triggers'
import type { TriggerConfig } from '@/triggers/types'
-import { buildGenericOutputs, gongSetupInstructions, gongTriggerOptions } from './utils'
+import {
+ buildGenericOutputs,
+ buildGongExtraFields,
+ gongSetupInstructions,
+ gongTriggerOptions,
+} from './utils'
/**
* Gong Generic Webhook Trigger
@@ -16,55 +22,13 @@ export const gongWebhookTrigger: TriggerConfig = {
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',
- },
- },
- ],
+ subBlocks: buildTriggerSubBlocks({
+ triggerId: 'gong_webhook',
+ triggerOptions: gongTriggerOptions,
+ includeDropdown: true,
+ setupInstructions: gongSetupInstructions('All Events'),
+ extraFields: buildGongExtraFields('gong_webhook'),
+ }),
outputs: buildGenericOutputs(),
diff --git a/apps/sim/triggers/greenhouse/utils.test.ts b/apps/sim/triggers/greenhouse/utils.test.ts
new file mode 100644
index 00000000000..8883171c445
--- /dev/null
+++ b/apps/sim/triggers/greenhouse/utils.test.ts
@@ -0,0 +1,88 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { greenhouseHandler } from '@/lib/webhooks/providers/greenhouse'
+import { isGreenhouseEventMatch } from '@/triggers/greenhouse/utils'
+
+describe('isGreenhouseEventMatch', () => {
+ it('matches mapped trigger ids to Greenhouse action strings', () => {
+ expect(isGreenhouseEventMatch('greenhouse_new_application', 'new_candidate_application')).toBe(
+ true
+ )
+ expect(isGreenhouseEventMatch('greenhouse_new_application', 'hire_candidate')).toBe(false)
+ })
+
+ it('rejects unknown trigger ids (no permissive fallback)', () => {
+ expect(isGreenhouseEventMatch('greenhouse_unknown', 'new_candidate_application')).toBe(false)
+ })
+
+ it('builds fallback idempotency keys for nested offer payloads', () => {
+ const key = greenhouseHandler.extractIdempotencyId!({
+ action: 'offer_deleted',
+ payload: {
+ offer: {
+ id: 42,
+ version: 3,
+ },
+ },
+ })
+
+ expect(key).toBe('greenhouse:offer_deleted:offer:42:3')
+ })
+})
+
+describe('greenhouseHandler.formatInput', () => {
+ it('exposes application, candidate, and job ids alongside action and payload', async () => {
+ const { input } = await greenhouseHandler.formatInput!({
+ webhook: {},
+ workflow: { id: 'w', userId: 'u' },
+ body: {
+ action: 'new_candidate_application',
+ payload: {
+ application: {
+ id: 100,
+ candidate: { id: 200 },
+ jobs: [{ id: 300 }],
+ },
+ },
+ },
+ headers: {},
+ requestId: 't',
+ })
+ expect(input).toMatchObject({
+ action: 'new_candidate_application',
+ applicationId: 100,
+ candidateId: 200,
+ jobId: null,
+ })
+ expect(input).toHaveProperty('payload')
+ })
+
+ it('reads job id from payload.job and offer job_id', async () => {
+ const jobFromNested = await greenhouseHandler.formatInput!({
+ webhook: {},
+ workflow: { id: 'w', userId: 'u' },
+ body: {
+ action: 'job_created',
+ payload: { job: { id: 55 } },
+ },
+ headers: {},
+ requestId: 't',
+ })
+ expect((jobFromNested.input as Record).jobId).toBe(55)
+
+ const jobFromOffer = await greenhouseHandler.formatInput!({
+ webhook: {},
+ workflow: { id: 'w', userId: 'u' },
+ body: {
+ action: 'offer_created',
+ payload: { id: 1, application_id: 2, job_id: 66 },
+ },
+ headers: {},
+ requestId: 't',
+ })
+ expect((jobFromOffer.input as Record).jobId).toBe(66)
+ expect((jobFromOffer.input as Record).applicationId).toBe(2)
+ })
+})
diff --git a/apps/sim/triggers/greenhouse/utils.ts b/apps/sim/triggers/greenhouse/utils.ts
index 15972379e03..8210761dc8c 100644
--- a/apps/sim/triggers/greenhouse/utils.ts
+++ b/apps/sim/triggers/greenhouse/utils.ts
@@ -1,6 +1,26 @@
import type { SubBlockConfig } from '@/blocks/types'
import type { TriggerOutput } from '@/triggers/types'
+/**
+ * Top-level ids mirrored from the webhook JSON for ergonomics (see Greenhouse webhook common attributes).
+ * Always present on `formatInput`; use null when not applicable to the event.
+ */
+const greenhouseIndexedOutputs = {
+ applicationId: {
+ type: 'number',
+ description:
+ 'Application id when present (`payload.application.id` or flat `payload.application_id` on offers)',
+ },
+ candidateId: {
+ type: 'number',
+ description: 'Candidate id when `payload.application.candidate.id` is present',
+ },
+ jobId: {
+ type: 'number',
+ description: 'Job id from `payload.job.id` or flat `payload.job_id` when present',
+ },
+} as const
+
/**
* Dropdown options for the Greenhouse trigger type selector.
*/
@@ -12,7 +32,7 @@ export const greenhouseTriggerOptions = [
{ 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' },
+ { label: 'All configured Greenhouse events', id: 'greenhouse_webhook' },
]
/**
@@ -34,8 +54,8 @@ export const GREENHOUSE_EVENT_MAP: Record = {
*/
export function isGreenhouseEventMatch(triggerId: string, action: string): boolean {
const expectedAction = GREENHOUSE_EVENT_MAP[triggerId]
- if (!expectedAction) {
- return true
+ if (expectedAction === undefined) {
+ return false
}
return action === expectedAction
}
@@ -51,7 +71,8 @@ export function buildGreenhouseExtraFields(triggerId: string): SubBlockConfig[]
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.',
+ description:
+ 'When set, requests must include a valid Signature header (HMAC-SHA256). If left empty, the endpoint does not verify signatures—only use on a private URL you fully control.',
password: true,
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
@@ -59,6 +80,17 @@ export function buildGreenhouseExtraFields(triggerId: string): SubBlockConfig[]
]
}
+function buildSourceOutputs(): Record {
+ return {
+ id: { type: 'number', description: 'Source ID' },
+ name: { type: 'string', description: 'Source name when provided by Greenhouse' },
+ public_name: {
+ type: 'string',
+ description: 'Public-facing source name when provided by Greenhouse',
+ },
+ }
+}
+
/**
* Generates HTML setup instructions for Greenhouse webhooks.
* Webhooks are manually configured in the Greenhouse admin panel.
@@ -69,8 +101,8 @@ export function greenhouseSetupInstructions(eventType: string): string {
'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.`,
+ 'Enter a Secret Key for HMAC signature verification (recommended). Leave empty only if you accept unauthenticated POSTs to this URL.',
+ `Under When, select the appropriate ${eventType}.`,
'Click Create Webhook to save.',
'Click "Save" above to activate your trigger.',
]
@@ -91,6 +123,7 @@ export function greenhouseSetupInstructions(eventType: string): string {
export function buildCandidateHiredOutputs(): Record {
return {
action: { type: 'string', description: 'The webhook event type (hire_candidate)' },
+ ...greenhouseIndexedOutputs,
payload: {
application: {
id: { type: 'number', description: 'Application ID' },
@@ -114,10 +147,7 @@ export function buildCandidateHiredOutputs(): Record {
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' },
- },
+ source: buildSourceOutputs(),
offer: {
id: { type: 'number', description: 'Offer ID' },
version: { type: 'number', description: 'Offer version' },
@@ -137,6 +167,7 @@ export function buildCandidateHiredOutputs(): Record {
export function buildNewApplicationOutputs(): Record {
return {
action: { type: 'string', description: 'The webhook event type (new_candidate_application)' },
+ ...greenhouseIndexedOutputs,
payload: {
application: {
id: { type: 'number', description: 'Application ID' },
@@ -160,10 +191,7 @@ export function buildNewApplicationOutputs(): Record {
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' },
- },
+ source: buildSourceOutputs(),
answers: { type: 'json', description: 'Application question answers' },
attachments: { type: 'json', description: 'Application attachments' },
custom_fields: { type: 'json', description: 'Application custom fields' },
@@ -179,6 +207,7 @@ export function buildNewApplicationOutputs(): Record {
export function buildCandidateStageChangeOutputs(): Record {
return {
action: { type: 'string', description: 'The webhook event type (candidate_stage_change)' },
+ ...greenhouseIndexedOutputs,
payload: {
application: {
id: { type: 'number', description: 'Application ID' },
@@ -201,10 +230,7 @@ export function buildCandidateStageChangeOutputs(): Record {
return {
action: { type: 'string', description: 'The webhook event type (reject_candidate)' },
+ ...greenhouseIndexedOutputs,
payload: {
application: {
id: { type: 'number', description: 'Application ID' },
@@ -256,6 +283,7 @@ export function buildCandidateRejectedOutputs(): Record {
export function buildOfferCreatedOutputs(): Record {
return {
action: { type: 'string', description: 'The webhook event type (offer_created)' },
+ ...greenhouseIndexedOutputs,
payload: {
id: { type: 'number', description: 'Offer ID' },
application_id: { type: 'number', description: 'Associated application ID' },
@@ -300,6 +328,7 @@ function buildJobPayload(): Record {
export function buildJobCreatedOutputs(): Record {
return {
action: { type: 'string', description: 'The webhook event type (job_created)' },
+ ...greenhouseIndexedOutputs,
payload: { job: buildJobPayload() },
} as Record
}
@@ -311,6 +340,7 @@ export function buildJobCreatedOutputs(): Record {
export function buildJobUpdatedOutputs(): Record {
return {
action: { type: 'string', description: 'The webhook event type (job_updated)' },
+ ...greenhouseIndexedOutputs,
payload: { job: buildJobPayload() },
} as Record
}
@@ -321,6 +351,7 @@ export function buildJobUpdatedOutputs(): Record {
export function buildWebhookOutputs(): Record {
return {
action: { type: 'string', description: 'The webhook event type' },
+ ...greenhouseIndexedOutputs,
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
index de436a89748..a527442366f 100644
--- a/apps/sim/triggers/greenhouse/webhook.ts
+++ b/apps/sim/triggers/greenhouse/webhook.ts
@@ -9,22 +9,22 @@ import {
import type { TriggerConfig } from '@/triggers/types'
/**
- * Greenhouse Generic Webhook Trigger
- *
- * Accepts all Greenhouse webhook events without filtering.
+ * Greenhouse generic webhook trigger.
+ * Event filtering is determined by which events you enable on the Greenhouse webhook endpoint.
*/
export const greenhouseWebhookTrigger: TriggerConfig = {
id: 'greenhouse_webhook',
- name: 'Greenhouse Webhook (All Events)',
+ name: 'Greenhouse Webhook (Endpoint Events)',
provider: 'greenhouse',
- description: 'Trigger workflow on any Greenhouse webhook event',
+ description:
+ 'Trigger on whichever event types you select for this URL in Greenhouse. Sim does not filter deliveries for this trigger.',
version: '1.0.0',
icon: GreenhouseIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'greenhouse_webhook',
triggerOptions: greenhouseTriggerOptions,
- setupInstructions: greenhouseSetupInstructions('All Events'),
+ setupInstructions: greenhouseSetupInstructions('Greenhouse event types for this URL'),
extraFields: buildGreenhouseExtraFields('greenhouse_webhook'),
}),
diff --git a/apps/sim/triggers/linear/utils.test.ts b/apps/sim/triggers/linear/utils.test.ts
new file mode 100644
index 00000000000..eec0c6a6eba
--- /dev/null
+++ b/apps/sim/triggers/linear/utils.test.ts
@@ -0,0 +1,20 @@
+/**
+ * @vitest-environment node
+ */
+
+import { describe, expect, it } from 'vitest'
+import { isLinearEventMatch } from '@/triggers/linear/utils'
+
+describe('isLinearEventMatch', () => {
+ it('returns false for unknown trigger ids (fail closed)', () => {
+ expect(isLinearEventMatch('linear_unknown_trigger', 'Issue', 'create')).toBe(false)
+ })
+
+ it('returns true when type and action match a known trigger', () => {
+ expect(isLinearEventMatch('linear_issue_created', 'Issue', 'create')).toBe(true)
+ })
+
+ it('normalizes _v2 suffix when matching', () => {
+ expect(isLinearEventMatch('linear_issue_created_v2', 'Issue', 'create')).toBe(true)
+ })
+})
diff --git a/apps/sim/triggers/linear/utils.ts b/apps/sim/triggers/linear/utils.ts
index 788a6f22127..de9f9122275 100644
--- a/apps/sim/triggers/linear/utils.ts
+++ b/apps/sim/triggers/linear/utils.ts
@@ -194,8 +194,8 @@ export function buildLinearV2SubBlocks(options: {
}
/**
- * Shared user/actor output schema
- * Note: Linear webhooks only include id, name, and type in actor objects
+ * Shared user/actor output schema (Linear data-change webhook `actor` object).
+ * @see https://linear.app/developers/webhooks — actor may be a User, OauthClient, or Integration; `type` is mapped to `actorType` (TriggerOutput reserves nested `type` for field kinds).
*/
export const userOutputs = {
id: {
@@ -206,9 +206,18 @@ export const userOutputs = {
type: 'string',
description: 'User display name',
},
- user_type: {
+ /** Linear sends this as `actor.type`; exposed as `actorType` here (TriggerOutput reserves `type`). */
+ actorType: {
type: 'string',
- description: 'Actor type (user, bot, etc.)',
+ description: 'Actor type from Linear (e.g. user, OauthClient, Integration)',
+ },
+ email: {
+ type: 'string',
+ description: 'Actor email (present for user actors in Linear webhook payloads)',
+ },
+ url: {
+ type: 'string',
+ description: 'Actor profile URL in Linear (distinct from the top-level subject entity `url`)',
},
} as const
@@ -297,6 +306,10 @@ export function buildIssueOutputs(): Record {
type: 'string',
description: 'Event creation timestamp',
},
+ url: {
+ type: 'string',
+ description: 'URL of the subject entity in Linear (top-level webhook payload)',
+ },
actor: userOutputs,
data: {
id: {
@@ -476,6 +489,10 @@ export function buildCommentOutputs(): Record {
type: 'string',
description: 'Event creation timestamp',
},
+ url: {
+ type: 'string',
+ description: 'URL of the subject entity in Linear (top-level webhook payload)',
+ },
actor: userOutputs,
data: {
id: {
@@ -486,6 +503,10 @@ export function buildCommentOutputs(): Record {
type: 'string',
description: 'Comment body text',
},
+ edited: {
+ type: 'boolean',
+ description: 'Whether the comment body has been edited (Linear webhook payload field)',
+ },
url: {
type: 'string',
description: 'Comment URL',
@@ -563,6 +584,10 @@ export function buildProjectOutputs(): Record {
type: 'string',
description: 'Event creation timestamp',
},
+ url: {
+ type: 'string',
+ description: 'URL of the subject entity in Linear (top-level webhook payload)',
+ },
actor: userOutputs,
data: {
id: {
@@ -706,6 +731,10 @@ export function buildCycleOutputs(): Record {
type: 'string',
description: 'Event creation timestamp',
},
+ url: {
+ type: 'string',
+ description: 'URL of the subject entity in Linear (top-level webhook payload)',
+ },
actor: userOutputs,
data: {
id: {
@@ -809,6 +838,10 @@ export function buildLabelOutputs(): Record {
type: 'string',
description: 'Event creation timestamp',
},
+ url: {
+ type: 'string',
+ description: 'URL of the subject entity in Linear (top-level webhook payload)',
+ },
actor: userOutputs,
data: {
id: {
@@ -896,6 +929,10 @@ export function buildProjectUpdateOutputs(): Record {
type: 'string',
description: 'Event creation timestamp',
},
+ url: {
+ type: 'string',
+ description: 'URL of the subject entity in Linear (top-level webhook payload)',
+ },
actor: userOutputs,
data: {
id: {
@@ -971,6 +1008,10 @@ export function buildCustomerRequestOutputs(): Record {
type: 'string',
description: 'Event creation timestamp',
},
+ url: {
+ type: 'string',
+ description: 'URL of the subject entity in Linear (top-level webhook payload)',
+ },
actor: userOutputs,
data: {
id: {
@@ -1049,7 +1090,7 @@ export function isLinearEventMatch(triggerId: string, eventType: string, action?
const normalizedId = triggerId.replace(/_v2$/, '')
const config = eventMap[normalizedId]
if (!config) {
- return true // Unknown trigger, allow through
+ return false
}
// Check event type
diff --git a/apps/sim/triggers/linear/webhook.ts b/apps/sim/triggers/linear/webhook.ts
index 9239e4b494f..cf6eef2d09a 100644
--- a/apps/sim/triggers/linear/webhook.ts
+++ b/apps/sim/triggers/linear/webhook.ts
@@ -6,7 +6,8 @@ export const linearWebhookTrigger: TriggerConfig = {
id: 'linear_webhook',
name: 'Linear Webhook',
provider: 'linear',
- description: 'Trigger workflow from any Linear webhook event',
+ description:
+ 'Trigger workflow from Linear events you select when creating the webhook in Linear (not guaranteed to be every model or event type).',
version: '1.0.0',
icon: LinearIcon,
@@ -58,7 +59,7 @@ export const linearWebhookTrigger: TriggerConfig = {
type: 'text',
defaultValue: linearSetupInstructions(
'all events',
- 'This webhook will receive all Linear events. Use the type and action fields in the payload to filter and handle different event types.'
+ 'When you select resource types in Linear, you receive those data-change events only (see Linear webhooks for supported models). This is not every possible Linear event. Use the type and action fields in the payload to filter further.'
),
mode: 'trigger',
condition: {
@@ -93,6 +94,10 @@ export const linearWebhookTrigger: TriggerConfig = {
type: 'string',
description: 'Event creation timestamp',
},
+ url: {
+ type: 'string',
+ description: 'URL of the subject entity in Linear (top-level webhook payload)',
+ },
actor: userOutputs,
data: {
type: 'object',
@@ -109,8 +114,8 @@ export const linearWebhookTrigger: TriggerConfig = {
headers: {
'Content-Type': 'application/json',
'Linear-Event': 'Issue',
- 'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
- 'Linear-Signature': 'sha256...',
+ 'Linear-Delivery': '234d1a4e-b617-4388-90fe-adc3633d6b72',
+ 'Linear-Signature': '766e1d90a96e2f5ecec342a99c5552999dd95d49250171b902d703fd674f5086',
'User-Agent': 'Linear-Webhook',
},
},
diff --git a/apps/sim/triggers/linear/webhook_v2.ts b/apps/sim/triggers/linear/webhook_v2.ts
index 4b84363ca7b..567c47213bb 100644
--- a/apps/sim/triggers/linear/webhook_v2.ts
+++ b/apps/sim/triggers/linear/webhook_v2.ts
@@ -6,7 +6,8 @@ export const linearWebhookV2Trigger: TriggerConfig = {
id: 'linear_webhook_v2',
name: 'Linear Webhook',
provider: 'linear',
- description: 'Trigger workflow from any Linear webhook event',
+ description:
+ 'Trigger workflow from Linear data-change events included in this webhook subscription (Issues, Comments, Projects, etc.—not every Linear model).',
version: '2.0.0',
icon: LinearIcon,
@@ -14,7 +15,7 @@ export const linearWebhookV2Trigger: TriggerConfig = {
triggerId: 'linear_webhook_v2',
eventType: 'All Events',
additionalNotes:
- 'This webhook will receive all Linear events. Use the type and action fields in the payload to filter and handle different event types.',
+ 'Sim registers this webhook for Issues, Comments, Projects, Cycles, Issue labels, Project updates, and Customer requests—matching what the Linear API allows in one subscription. It does not include every model Linear documents separately (e.g. Documents, Reactions). Use type and action in the payload to filter.',
}),
outputs: {
@@ -42,6 +43,10 @@ export const linearWebhookV2Trigger: TriggerConfig = {
type: 'string',
description: 'Event creation timestamp',
},
+ url: {
+ type: 'string',
+ description: 'URL of the subject entity in Linear (top-level webhook payload)',
+ },
actor: userOutputs,
data: {
type: 'object',
@@ -58,8 +63,8 @@ export const linearWebhookV2Trigger: TriggerConfig = {
headers: {
'Content-Type': 'application/json',
'Linear-Event': 'Issue',
- 'Linear-Delivery': 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
- 'Linear-Signature': 'sha256...',
+ 'Linear-Delivery': '234d1a4e-b617-4388-90fe-adc3633d6b72',
+ 'Linear-Signature': '766e1d90a96e2f5ecec342a99c5552999dd95d49250171b902d703fd674f5086',
'User-Agent': 'Linear-Webhook',
},
},
diff --git a/apps/sim/triggers/notion/utils.ts b/apps/sim/triggers/notion/utils.ts
index df8d7a4b5b2..c220252de22 100644
--- a/apps/sim/triggers/notion/utils.ts
+++ b/apps/sim/triggers/notion/utils.ts
@@ -28,9 +28,18 @@ export function notionSetupInstructions(eventType: string): string {
'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.',
+ 'Paste the same verification_token into the Webhook Secret field above to enable signature verification on incoming events.',
'Ensure the integration has access to the pages/databases you want to monitor (share them with the integration).',
]
+ if (eventType === 'comment.created') {
+ instructions.splice(
+ 7,
+ 0,
+ 'Enable the comment read capability in your Notion integration settings so comment events can be delivered.'
+ )
+ }
+
return instructions
.map(
(instruction, index) =>
@@ -48,9 +57,9 @@ export function buildNotionExtraFields(triggerId: string): SubBlockConfig[] {
id: 'webhookSecret',
title: 'Webhook Secret',
type: 'short-input',
- placeholder: 'Enter your Notion webhook signing secret',
+ placeholder: 'Enter your Notion verification_token',
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.',
+ 'The verification_token sent by Notion during webhook setup. This same token is used to verify X-Notion-Signature HMAC headers on all subsequent webhook deliveries.',
password: true,
required: false,
mode: 'trigger',
@@ -70,11 +79,20 @@ function buildBaseOutputs(): Record {
description: 'Event type (e.g., page.created, database.schema_updated)',
},
timestamp: { type: 'string', description: 'ISO 8601 timestamp of the event' },
+ api_version: { type: 'string', description: 'Notion API version included with 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' },
+ attempt_number: {
+ type: 'number',
+ description: 'Delivery attempt number (1-8 per Notion retries)',
+ },
+ accessible_by: {
+ type: 'array',
+ description:
+ 'Users and bots with access to the entity (`id` + `type` per object); `type` is `person` or `bot`. Omitted on some deliveries (treat as empty).',
+ },
}
}
@@ -83,8 +101,14 @@ function buildBaseOutputs(): Record {
*/
function buildEntityOutputs(): Record {
return {
- id: { type: 'string', description: 'Entity ID (page or database ID)' },
- entity_type: { type: 'string', description: 'Entity type (page or database)' },
+ id: {
+ type: 'string',
+ description: 'Entity ID (page, database, block, comment, or data source ID)',
+ },
+ entity_type: {
+ type: 'string',
+ description: 'Entity type: `page`, `database`, `block`, `comment`, or `data_source`',
+ },
}
}
@@ -96,13 +120,28 @@ export function buildPageEventOutputs(): Record {
...buildBaseOutputs(),
authors: {
type: 'array',
- description: 'Array of users who triggered the event',
+ description:
+ 'Actors who triggered the event (`id` + `type` per object); `type` is `person`, `bot`, or `agent` per Notion',
},
entity: buildEntityOutputs(),
data: {
+ updated_blocks: {
+ type: 'array',
+ description: 'Blocks updated as part of the event, when provided by Notion',
+ },
+ updated_properties: {
+ type: 'array',
+ description: 'Property IDs updated as part of the event, when provided by Notion',
+ },
parent: {
- id: { type: 'string', description: 'Parent page or database ID' },
- parent_type: { type: 'string', description: 'Parent type (database, page, workspace)' },
+ id: {
+ type: 'string',
+ description: 'Parent page, database, workspace (space), or block ID',
+ },
+ parent_type: {
+ type: 'string',
+ description: 'Parent type: `page`, `database`, `block`, `workspace`, or `space`',
+ },
},
},
}
@@ -116,13 +155,25 @@ export function buildDatabaseEventOutputs(): Record {
...buildBaseOutputs(),
authors: {
type: 'array',
- description: 'Array of users who triggered the event',
+ description:
+ 'Actors who triggered the event (`id` + `type` per object); `type` is `person`, `bot`, or `agent` per Notion',
},
entity: buildEntityOutputs(),
data: {
+ updated_blocks: {
+ type: 'array',
+ description: 'Blocks updated as part of the event, when provided by Notion',
+ },
+ updated_properties: {
+ type: 'array',
+ description: 'Database properties updated as part of the event, when provided by Notion',
+ },
parent: {
- id: { type: 'string', description: 'Parent page or workspace ID' },
- parent_type: { type: 'string', description: 'Parent type (page, workspace)' },
+ id: { type: 'string', description: 'Parent page, database, workspace, or space ID' },
+ parent_type: {
+ type: 'string',
+ description: 'Parent type: `page`, `database`, `workspace`, or `space`',
+ },
},
},
}
@@ -136,16 +187,18 @@ export function buildCommentEventOutputs(): Record {
...buildBaseOutputs(),
authors: {
type: 'array',
- description: 'Array of users who triggered the event',
+ description:
+ 'Actors who triggered the event (`id` + `type` per object); `type` is `person`, `bot`, or `agent` per Notion',
},
entity: {
id: { type: 'string', description: 'Comment ID' },
entity_type: { type: 'string', description: 'Entity type (comment)' },
},
data: {
+ page_id: { type: 'string', description: 'Page ID that owns the comment thread' },
parent: {
- id: { type: 'string', description: 'Parent page ID' },
- parent_type: { type: 'string', description: 'Parent type (page)' },
+ id: { type: 'string', description: 'Parent page or block ID' },
+ parent_type: { type: 'string', description: 'Parent type (page or block)' },
},
},
}
@@ -159,12 +212,28 @@ export function buildGenericWebhookOutputs(): Record {
...buildBaseOutputs(),
authors: {
type: 'array',
- description: 'Array of users who triggered the event',
+ description:
+ 'Actors who triggered the event (`id` + `type` per object); `type` is `person`, `bot`, or `agent` per Notion',
},
entity: buildEntityOutputs(),
data: {
- type: 'json',
- description: 'Event-specific data including parent information',
+ parent: {
+ id: { type: 'string', description: 'Parent entity ID, when provided by Notion' },
+ parent_type: {
+ type: 'string',
+ description:
+ 'Parent type (`page`, `database`, `block`, `workspace`, `space`, …), when present',
+ },
+ },
+ page_id: { type: 'string', description: 'Page ID related to the event, when present' },
+ updated_blocks: {
+ type: 'array',
+ description: 'Blocks updated as part of the event, when provided by Notion',
+ },
+ updated_properties: {
+ type: 'array',
+ description: 'Updated properties included with the event, when provided by Notion',
+ },
},
}
}
@@ -178,7 +247,7 @@ const TRIGGER_EVENT_MAP: Record = {
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_schema_updated: ['database.schema_updated', 'data_source.schema_updated'],
notion_database_deleted: ['database.deleted'],
notion_comment_created: ['comment.created'],
}
diff --git a/apps/sim/triggers/resend/utils.ts b/apps/sim/triggers/resend/utils.ts
index 3ab99c35692..13f79262838 100644
--- a/apps/sim/triggers/resend/utils.ts
+++ b/apps/sim/triggers/resend/utils.ts
@@ -1,5 +1,42 @@
import type { TriggerOutput } from '@/triggers/types'
+/**
+ * Maps Sim Resend trigger IDs to a single Resend webhook event type.
+ * Kept in sync with subscription registration in `resend` webhook provider.
+ */
+export const RESEND_TRIGGER_TO_EVENT_TYPE: 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',
+}
+
+/**
+ * Event types registered for the catch-all `resend_webhook` trigger (API + matchEvent).
+ */
+export const RESEND_ALL_WEBHOOK_EVENT_TYPES: string[] = [
+ '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',
+]
+
/**
* Shared trigger dropdown options for all Resend triggers
*/
@@ -58,6 +95,7 @@ export function buildResendExtraFields(triggerId: string) {
/**
* Common fields present in all Resend email webhook payloads
+ * (see https://resend.com/docs/dashboard/webhooks/introduction — example `data` object).
*/
const commonEmailOutputs = {
type: {
@@ -66,12 +104,29 @@ const commonEmailOutputs = {
},
created_at: {
type: 'string',
- description: 'Event creation timestamp (ISO 8601)',
+ description: 'Webhook event creation timestamp (ISO 8601), top-level `created_at`',
+ },
+ data_created_at: {
+ type: 'string',
+ description:
+ 'Email record timestamp from payload `data.created_at` (ISO 8601), when present — distinct from top-level `created_at`',
},
email_id: {
type: 'string',
description: 'Unique email identifier',
},
+ broadcast_id: {
+ type: 'string',
+ description: 'Broadcast ID associated with the email, when sent as part of a broadcast',
+ },
+ template_id: {
+ type: 'string',
+ description: 'Template ID used to send the email, when applicable',
+ },
+ tags: {
+ type: 'json',
+ description: 'Tag key/value metadata attached to the email (payload `data.tags`)',
+ },
from: {
type: 'string',
description: 'Sender email address',
@@ -92,6 +147,14 @@ const recipientOutputs = {
},
} as const
+const resendEventDataOutput: Record = {
+ data: {
+ type: 'json',
+ description:
+ 'Raw event `data` from Resend (shape varies by event type: email, contact, domain, etc.)',
+ },
+}
+
/**
* Build outputs for email sent events
*/
@@ -99,6 +162,7 @@ export function buildEmailSentOutputs(): Record {
return {
...commonEmailOutputs,
...recipientOutputs,
+ ...resendEventDataOutput,
} as Record
}
@@ -109,6 +173,7 @@ export function buildEmailDeliveredOutputs(): Record {
return {
...commonEmailOutputs,
...recipientOutputs,
+ ...resendEventDataOutput,
} as Record
}
@@ -119,6 +184,7 @@ export function buildEmailBouncedOutputs(): Record {
return {
...commonEmailOutputs,
...recipientOutputs,
+ ...resendEventDataOutput,
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' },
@@ -132,6 +198,7 @@ export function buildEmailComplainedOutputs(): Record {
return {
...commonEmailOutputs,
...recipientOutputs,
+ ...resendEventDataOutput,
} as Record
}
@@ -142,6 +209,7 @@ export function buildEmailOpenedOutputs(): Record {
return {
...commonEmailOutputs,
...recipientOutputs,
+ ...resendEventDataOutput,
} as Record
}
@@ -152,6 +220,7 @@ export function buildEmailClickedOutputs(): Record {
return {
...commonEmailOutputs,
...recipientOutputs,
+ ...resendEventDataOutput,
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)' },
@@ -166,6 +235,7 @@ export function buildEmailFailedOutputs(): Record {
return {
...commonEmailOutputs,
...recipientOutputs,
+ ...resendEventDataOutput,
} as Record
}
@@ -177,6 +247,7 @@ export function buildResendOutputs(): Record {
return {
...commonEmailOutputs,
...recipientOutputs,
+ ...resendEventDataOutput,
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' },
diff --git a/apps/sim/triggers/resend/webhook.ts b/apps/sim/triggers/resend/webhook.ts
index e320f0be7aa..e5990a5d1ab 100644
--- a/apps/sim/triggers/resend/webhook.ts
+++ b/apps/sim/triggers/resend/webhook.ts
@@ -16,7 +16,8 @@ export const resendWebhookTrigger: TriggerConfig = {
id: 'resend_webhook',
name: 'Resend Webhook (All Events)',
provider: 'resend',
- description: 'Trigger workflow on any Resend webhook event',
+ description:
+ 'Trigger on Resend webhook events we subscribe to (email lifecycle, contacts, domains—see Resend docs). Flattened email fields may be null for non-email events; use data for the full payload.',
version: '1.0.0',
icon: ResendIcon,
diff --git a/apps/sim/triggers/salesforce/case_status_changed.ts b/apps/sim/triggers/salesforce/case_status_changed.ts
index a3ad5802112..9506c615f87 100644
--- a/apps/sim/triggers/salesforce/case_status_changed.ts
+++ b/apps/sim/triggers/salesforce/case_status_changed.ts
@@ -1,6 +1,7 @@
import { SalesforceIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
+ buildSalesforceAuthOnlyFields,
buildSalesforceCaseStatusOutputs,
salesforceSetupInstructions,
salesforceTriggerOptions,
@@ -22,6 +23,7 @@ export const salesforceCaseStatusChangedTrigger: TriggerConfig = {
triggerId: 'salesforce_case_status_changed',
triggerOptions: salesforceTriggerOptions,
setupInstructions: salesforceSetupInstructions('Case Status Changed'),
+ extraFields: buildSalesforceAuthOnlyFields('salesforce_case_status_changed'),
}),
outputs: buildSalesforceCaseStatusOutputs(),
diff --git a/apps/sim/triggers/salesforce/opportunity_stage_changed.ts b/apps/sim/triggers/salesforce/opportunity_stage_changed.ts
index 43d72a972c3..495757a732c 100644
--- a/apps/sim/triggers/salesforce/opportunity_stage_changed.ts
+++ b/apps/sim/triggers/salesforce/opportunity_stage_changed.ts
@@ -1,6 +1,7 @@
import { SalesforceIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
+ buildSalesforceAuthOnlyFields,
buildSalesforceOpportunityStageOutputs,
salesforceSetupInstructions,
salesforceTriggerOptions,
@@ -22,6 +23,7 @@ export const salesforceOpportunityStageChangedTrigger: TriggerConfig = {
triggerId: 'salesforce_opportunity_stage_changed',
triggerOptions: salesforceTriggerOptions,
setupInstructions: salesforceSetupInstructions('Opportunity Stage Changed'),
+ extraFields: buildSalesforceAuthOnlyFields('salesforce_opportunity_stage_changed'),
}),
outputs: buildSalesforceOpportunityStageOutputs(),
diff --git a/apps/sim/triggers/salesforce/utils.ts b/apps/sim/triggers/salesforce/utils.ts
index a2c1db4b715..233274bc702 100644
--- a/apps/sim/triggers/salesforce/utils.ts
+++ b/apps/sim/triggers/salesforce/utils.ts
@@ -1,3 +1,4 @@
+import { extractSalesforceObjectTypeFromPayload } from '@/lib/webhooks/providers/salesforce'
import type { SubBlockConfig } from '@/blocks/types'
import type { TriggerOutput } from '@/triggers/types'
@@ -13,34 +14,169 @@ export const salesforceTriggerOptions = [
{ label: 'Generic Webhook (All Events)', id: 'salesforce_webhook' },
]
+function normalizeToken(s: string): string {
+ return s
+ .trim()
+ .toLowerCase()
+ .replace(/[\s-]+/g, '_')
+}
+
+const RECORD_CREATED = new Set([
+ 'record_created',
+ 'created',
+ 'create',
+ 'after_insert',
+ 'afterinsert',
+ 'insert',
+])
+
+const RECORD_UPDATED = new Set([
+ 'record_updated',
+ 'updated',
+ 'update',
+ 'after_update',
+ 'afterupdate',
+])
+
+const RECORD_DELETED = new Set([
+ 'record_deleted',
+ 'deleted',
+ 'delete',
+ 'after_delete',
+ 'afterdelete',
+])
+
+const OPP_STAGE = new Set([
+ 'opportunity_stage_changed',
+ 'stage_changed',
+ 'stage_change',
+ 'opportunity_stage_change',
+ 'opportunitystagechanged',
+])
+
+const CASE_STATUS = new Set([
+ 'case_status_changed',
+ 'status_changed',
+ 'status_change',
+ 'case_status_change',
+ 'casestatuschanged',
+])
+
+function matchesRecordTrigger(triggerId: string, normalizedEvent: string): boolean {
+ if (triggerId === 'salesforce_record_created') {
+ return RECORD_CREATED.has(normalizedEvent)
+ }
+ if (triggerId === 'salesforce_record_updated') {
+ return RECORD_UPDATED.has(normalizedEvent)
+ }
+ if (triggerId === 'salesforce_record_deleted') {
+ return RECORD_DELETED.has(normalizedEvent)
+ }
+ return false
+}
+
+/**
+ * Server-side filter for Salesforce Flow (JSON) payloads.
+ * Users should send a string `eventType` (or `simEventType`) from the Flow body.
+ * Optional `objectType` in provider config is enforced against payload when set.
+ */
+export function isSalesforceEventMatch(
+ triggerId: string,
+ body: Record,
+ configuredObjectType?: string
+): boolean {
+ if (triggerId === 'salesforce_webhook') {
+ const want = configuredObjectType?.trim()
+ if (!want) {
+ return true
+ }
+ const got = extractSalesforceObjectTypeFromPayload(body)
+ if (!got) {
+ return false
+ }
+ return normalizeToken(got) === normalizeToken(want)
+ }
+
+ const wantType = configuredObjectType?.trim()
+ const gotType = extractSalesforceObjectTypeFromPayload(body)
+ if (wantType) {
+ if (!gotType) {
+ return false
+ }
+ if (normalizeToken(gotType) !== normalizeToken(wantType)) {
+ return false
+ }
+ }
+
+ if (triggerId === 'salesforce_opportunity_stage_changed') {
+ if (gotType && normalizeToken(gotType) !== 'opportunity') {
+ return false
+ }
+ const etRaw =
+ (typeof body.eventType === 'string' && body.eventType) ||
+ (typeof body.simEventType === 'string' && body.simEventType) ||
+ ''
+ if (!etRaw.trim()) {
+ return Boolean(gotType && normalizeToken(gotType) === 'opportunity')
+ }
+ return OPP_STAGE.has(normalizeToken(etRaw))
+ }
+
+ if (triggerId === 'salesforce_case_status_changed') {
+ if (gotType && normalizeToken(gotType) !== 'case') {
+ return false
+ }
+ const etRaw =
+ (typeof body.eventType === 'string' && body.eventType) ||
+ (typeof body.simEventType === 'string' && body.simEventType) ||
+ ''
+ if (!etRaw.trim()) {
+ return Boolean(gotType && normalizeToken(gotType) === 'case')
+ }
+ return CASE_STATUS.has(normalizeToken(etRaw))
+ }
+
+ const etRaw =
+ (typeof body.eventType === 'string' && body.eventType) ||
+ (typeof body.simEventType === 'string' && body.simEventType) ||
+ ''
+
+ if (!etRaw.trim()) {
+ return false
+ }
+
+ const normalized = normalizeToken(etRaw)
+ return matchesRecordTrigger(triggerId, normalized)
+}
+
/**
* Generates HTML setup instructions for the Salesforce trigger.
- * Salesforce has no native webhook API — users must configure
- * Flow HTTP Callouts or Outbound Messages manually.
+ * Use Flow HTTP Callouts with a JSON body. Outbound Messages are SOAP/XML and are not supported.
*/
export function salesforceSetupInstructions(eventType: string): string {
const isGeneric = eventType === 'All Events'
const instructions = isGeneric
? [
- 'Copy the Webhook URL above.',
+ 'Copy the Webhook URL above and generate a Webhook Secret (any strong random string). Paste the secret in the Webhook Secret field here.',
+ 'In your Flow’s HTTP Callout, set header Authorization: Bearer <your secret> or X-Sim-Webhook-Secret: <your secret> (same value).',
'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.',
+ 'Add an Action that performs an HTTP Callout — method POST, Content-Type: application/json, and paste the webhook URL.',
+ 'Build the request body as JSON (not SOAP/XML). Include eventType and record fields (e.g. Id, Name). Outbound Messages use SOAP and will not work with this trigger.',
'Save and Activate the Flow(s).',
+ 'Save this trigger in Sim first so the URL is registered; Salesforce connectivity checks may arrive before the Flow runs.',
'Click "Save" above to activate your trigger.',
]
: [
- 'Copy the Webhook URL above.',
+ 'Copy the Webhook URL above and set a Webhook Secret. In the Flow HTTP Callout, send the same value as Authorization: Bearer … or X-Sim-Webhook-Secret: ….',
'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).',
+ `Select Record-Triggered Flow for the right object and ${eventType} as the entry condition.`,
+ 'Add an HTTP Callout — POST, JSON body, URL = webhook URL.',
+ `Include eventType in the JSON body using a value this trigger accepts (e.g. for Record Created use record_created, created, or after_insert).`,
+ 'If you use Object Type (Optional), you must also include matching type metadata in the JSON body (for example objectType, sobjectType, or attributes.type) or the event will be rejected.',
'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
@@ -51,21 +187,42 @@ export function salesforceSetupInstructions(eventType: string): string {
.join('')
}
-/**
- * Extra fields for Salesforce triggers (object type filter).
- */
+function salesforceWebhookSecretField(triggerId: string): SubBlockConfig {
+ return {
+ id: 'webhookSecret',
+ title: 'Webhook Secret',
+ type: 'short-input',
+ placeholder: 'Generate a secret and paste it here',
+ description:
+ 'Required. Use the same value in your Salesforce HTTP Callout as Bearer token or X-Sim-Webhook-Secret.',
+ password: true,
+ required: true,
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ }
+}
+
+function salesforceObjectTypeField(triggerId: string): SubBlockConfig {
+ return {
+ id: 'objectType',
+ title: 'Object Type (Optional)',
+ type: 'short-input',
+ placeholder: 'e.g., Account, Contact, Opportunity',
+ description:
+ 'When set, the payload must include matching object type metadata (for example objectType, sobjectType, or attributes.type) or the event is rejected.',
+ mode: 'trigger',
+ condition: { field: 'selectedTriggerId', value: triggerId },
+ }
+}
+
+/** Secret + optional object filter (record triggers and generic webhook). */
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 },
- },
- ]
+ return [salesforceWebhookSecretField(triggerId), salesforceObjectTypeField(triggerId)]
+}
+
+/** Webhook secret only (Opportunity / Case specialized triggers — object is implied). */
+export function buildSalesforceAuthOnlyFields(triggerId: string): SubBlockConfig[] {
+ return [salesforceWebhookSecretField(triggerId)]
}
/**
@@ -77,6 +234,12 @@ export function buildSalesforceRecordOutputs(): Record {
type: 'string',
description: 'The type of event (e.g., created, updated, deleted)',
},
+ /** Present when the Flow JSON body uses `simEventType` instead of or in addition to `eventType`. */
+ simEventType: {
+ type: 'string',
+ description:
+ 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.',
+ },
objectType: {
type: 'string',
description: 'Salesforce object type (e.g., Account, Contact, Lead)',
@@ -88,6 +251,14 @@ export function buildSalesforceRecordOutputs(): Record {
Name: { type: 'string', description: 'Record name' },
CreatedDate: { type: 'string', description: 'Record creation date' },
LastModifiedDate: { type: 'string', description: 'Last modification date' },
+ OwnerId: {
+ type: 'string',
+ description: 'Record owner ID (standard field when sent in the Flow body)',
+ },
+ SystemModstamp: {
+ type: 'string',
+ description: 'System modstamp from the record (ISO 8601) when included in the payload',
+ },
},
changedFields: { type: 'json', description: 'Fields that were changed (for update events)' },
payload: { type: 'json', description: 'Full webhook payload' },
@@ -100,6 +271,11 @@ export function buildSalesforceRecordOutputs(): Record {
export function buildSalesforceOpportunityStageOutputs(): Record {
return {
eventType: { type: 'string', description: 'The type of event' },
+ simEventType: {
+ type: 'string',
+ description:
+ 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.',
+ },
objectType: { type: 'string', description: 'Salesforce object type (Opportunity)' },
recordId: { type: 'string', description: 'Opportunity ID' },
timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' },
@@ -110,6 +286,8 @@ export function buildSalesforceOpportunityStageOutputs(): Record {
return {
eventType: { type: 'string', description: 'The type of event' },
+ simEventType: {
+ type: 'string',
+ description:
+ 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.',
+ },
objectType: { type: 'string', description: 'Salesforce object type (Case)' },
recordId: { type: 'string', description: 'Case ID' },
timestamp: { type: 'string', description: 'When the event occurred (ISO 8601)' },
@@ -132,6 +315,9 @@ export function buildSalesforceCaseStatusOutputs(): Record {
return {
eventType: { type: 'string', description: 'The type of event' },
+ simEventType: {
+ type: 'string',
+ description:
+ 'Optional alias from the payload (`simEventType`). Empty when only `eventType` is sent.',
+ },
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)' },
diff --git a/apps/sim/triggers/salesforce/webhook.ts b/apps/sim/triggers/salesforce/webhook.ts
index 32d0165db24..855f17174ec 100644
--- a/apps/sim/triggers/salesforce/webhook.ts
+++ b/apps/sim/triggers/salesforce/webhook.ts
@@ -1,6 +1,7 @@
import { SalesforceIcon } from '@/components/icons'
import { buildTriggerSubBlocks } from '@/triggers'
import {
+ buildSalesforceExtraFields,
buildSalesforceWebhookOutputs,
salesforceSetupInstructions,
salesforceTriggerOptions,
@@ -24,6 +25,7 @@ export const salesforceWebhookTrigger: TriggerConfig = {
triggerId: 'salesforce_webhook',
triggerOptions: salesforceTriggerOptions,
setupInstructions: salesforceSetupInstructions('All Events'),
+ extraFields: buildSalesforceExtraFields('salesforce_webhook'),
}),
outputs: buildSalesforceWebhookOutputs(),
diff --git a/apps/sim/triggers/vercel/utils.test.ts b/apps/sim/triggers/vercel/utils.test.ts
new file mode 100644
index 00000000000..baa3927265c
--- /dev/null
+++ b/apps/sim/triggers/vercel/utils.test.ts
@@ -0,0 +1,61 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import { vercelHandler } from '@/lib/webhooks/providers/vercel'
+import { isVercelEventMatch } from '@/triggers/vercel/utils'
+
+describe('isVercelEventMatch', () => {
+ it('matches specialized triggers to Vercel type strings', () => {
+ expect(isVercelEventMatch('vercel_deployment_created', 'deployment.created')).toBe(true)
+ expect(isVercelEventMatch('vercel_deployment_created', 'deployment.ready')).toBe(false)
+ })
+
+ it('does not match unknown trigger ids', () => {
+ expect(isVercelEventMatch('vercel_unknown_trigger', 'deployment.created')).toBe(false)
+ })
+
+ it('allows any event type for the curated generic trigger id', () => {
+ expect(isVercelEventMatch('vercel_webhook', 'deployment.succeeded')).toBe(true)
+ expect(isVercelEventMatch('vercel_webhook', undefined)).toBe(true)
+ })
+})
+
+describe('vercelHandler.formatInput', () => {
+ it('passes through documented deployment links, regions, meta, and domain.delegated', async () => {
+ const { input } = await vercelHandler.formatInput!({
+ webhook: {},
+ workflow: { id: 'w', userId: 'u' },
+ body: {
+ type: 'deployment.created',
+ id: 'evt_1',
+ createdAt: 1_700_000_000_000,
+ region: 'iad1',
+ payload: {
+ deployment: {
+ id: 'd1',
+ url: 'https://x.vercel.app',
+ name: 'x',
+ meta: { k: 'v' },
+ },
+ links: { deployment: 'https://vercel.com/d', project: 'https://vercel.com/p' },
+ regions: ['iad1', 'sfo1'],
+ domain: { name: 'example.com', delegated: true },
+ },
+ },
+ headers: {},
+ requestId: 't',
+ })
+ const i = input as Record
+ expect(i.links).toEqual({
+ deployment: 'https://vercel.com/d',
+ project: 'https://vercel.com/p',
+ })
+ expect(i.regions).toEqual(['iad1', 'sfo1'])
+ expect(i.deployment).toMatchObject({
+ id: 'd1',
+ meta: { k: 'v' },
+ })
+ expect(i.domain).toMatchObject({ name: 'example.com', delegated: true })
+ })
+})
diff --git a/apps/sim/triggers/vercel/utils.ts b/apps/sim/triggers/vercel/utils.ts
index e0f016dc9f5..ee895efac2f 100644
--- a/apps/sim/triggers/vercel/utils.ts
+++ b/apps/sim/triggers/vercel/utils.ts
@@ -12,9 +12,50 @@ export const vercelTriggerOptions = [
{ label: 'Project Created', id: 'vercel_project_created' },
{ label: 'Project Removed', id: 'vercel_project_removed' },
{ label: 'Domain Created', id: 'vercel_domain_created' },
- { label: 'Generic Webhook (All Events)', id: 'vercel_webhook' },
+ { label: 'Common events (curated list)', id: 'vercel_webhook' },
]
+/** Maps Sim trigger IDs to Vercel webhook `type` strings (see Vercel Webhooks API). */
+export const VERCEL_TRIGGER_EVENT_TYPES: Record = {
+ vercel_deployment_created: 'deployment.created',
+ vercel_deployment_ready: 'deployment.ready',
+ vercel_deployment_error: 'deployment.error',
+ vercel_deployment_canceled: 'deployment.canceled',
+ vercel_project_created: 'project.created',
+ vercel_project_removed: 'project.removed',
+ vercel_domain_created: 'domain.created',
+}
+
+/** Curated set used by the generic Vercel webhook trigger. */
+export const VERCEL_GENERIC_TRIGGER_EVENT_TYPES = [
+ 'deployment.created',
+ 'deployment.ready',
+ 'deployment.succeeded',
+ 'deployment.error',
+ 'deployment.canceled',
+ 'deployment.promoted',
+ 'project.created',
+ 'project.removed',
+ 'domain.created',
+ 'edge-config.created',
+ 'edge-config.deleted',
+] as const
+
+/**
+ * Returns whether the incoming Vercel event matches the configured trigger.
+ * `vercel_webhook` is handled only at subscription time; deliveries are not filtered here.
+ */
+export function isVercelEventMatch(triggerId: string, eventType: string | undefined): boolean {
+ if (triggerId === 'vercel_webhook') {
+ return true
+ }
+ const expected = VERCEL_TRIGGER_EVENT_TYPES[triggerId]
+ if (!expected) {
+ return false
+ }
+ return eventType === expected
+}
+
/**
* Generates setup instructions for Vercel webhooks.
* Webhooks are automatically created via the Vercel API.
@@ -84,7 +125,7 @@ const coreOutputs = {
},
id: {
type: 'string',
- description: 'Unique webhook delivery ID',
+ description: 'Unique webhook delivery ID (string)',
},
createdAt: {
type: 'number',
@@ -96,15 +137,50 @@ const coreOutputs = {
},
} as const
+/** Raw `payload` object from the Vercel webhook body (event-specific shape). */
+const payloadOutput = {
+ payload: { type: 'json' as const, description: 'Raw event payload from Vercel' },
+} as const
+
/**
- * Deployment-specific output fields
+ * Dashboard deep links included on many deployment webhook events (Vercel Webhooks API).
*/
-const deploymentOutputs = {
+const linksOutputs = {
+ links: {
+ deployment: {
+ type: 'string',
+ description: 'Vercel Dashboard URL for the deployment',
+ },
+ project: {
+ type: 'string',
+ description: 'Vercel Dashboard URL for the project',
+ },
+ },
+ regions: {
+ type: 'json',
+ description: 'Regions associated with the deployment (array), when provided by Vercel',
+ },
+} as const
+
+/** Normalized deployment object from `formatInput` (null when no deployment on the event). */
+const deploymentResourceOutputs = {
deployment: {
id: { type: 'string', description: 'Deployment ID' },
url: { type: 'string', description: 'Deployment URL' },
name: { type: 'string', description: 'Deployment name' },
+ meta: {
+ type: 'json',
+ description: 'Deployment metadata map (e.g. Git metadata), per Vercel Webhooks API',
+ },
},
+} as const
+
+/**
+ * Deployment-specific output fields
+ */
+const deploymentOutputs = {
+ ...linksOutputs,
+ ...deploymentResourceOutputs,
project: {
id: { type: 'string', description: 'Project ID' },
name: { type: 'string', description: 'Project name' },
@@ -117,12 +193,25 @@ const deploymentOutputs = {
},
target: {
type: 'string',
- description: 'Deployment target (production, preview)',
+ description: 'Deployment target (production, staging, or preview)',
},
plan: {
type: 'string',
description: 'Account plan type',
},
+ domain: {
+ name: { type: 'string', description: 'Domain name' },
+ delegated: {
+ type: 'boolean',
+ description: 'Whether the domain was delegated/shared when present on the payload',
+ },
+ },
+} as const
+
+const deploymentTargetPlanDomain = {
+ target: deploymentOutputs.target,
+ plan: deploymentOutputs.plan,
+ domain: deploymentOutputs.domain,
} as const
/**
@@ -147,6 +236,11 @@ const projectOutputs = {
const domainOutputs = {
domain: {
name: { type: 'string', description: 'Domain name' },
+ delegated: {
+ type: 'boolean',
+ description:
+ 'Whether the domain was delegated/shared (domain.created), per Vercel Webhooks API',
+ },
},
project: {
id: { type: 'string', description: 'Project ID' },
@@ -165,6 +259,7 @@ const domainOutputs = {
export function buildDeploymentOutputs(): Record {
return {
...coreOutputs,
+ ...payloadOutput,
...deploymentOutputs,
} as Record
}
@@ -175,7 +270,11 @@ export function buildDeploymentOutputs(): Record {
export function buildProjectOutputs(): Record {
return {
...coreOutputs,
+ ...payloadOutput,
+ ...linksOutputs,
+ ...deploymentResourceOutputs,
...projectOutputs,
+ ...deploymentTargetPlanDomain,
} as Record
}
@@ -185,6 +284,10 @@ export function buildProjectOutputs(): Record {
export function buildDomainOutputs(): Record {
return {
...coreOutputs,
+ ...payloadOutput,
+ ...linksOutputs,
+ ...deploymentResourceOutputs,
+ ...deploymentTargetPlanDomain,
...domainOutputs,
} as Record
}
@@ -196,31 +299,11 @@ export function buildVercelOutputs(): Record {
return {
...coreOutputs,
payload: { type: 'json', description: 'Full event payload' },
- deployment: {
- id: { type: 'string', description: 'Deployment ID' },
- url: { type: 'string', description: 'Deployment URL' },
- name: { type: 'string', description: 'Deployment name' },
- },
- project: {
- id: { type: 'string', description: 'Project ID' },
- name: { type: 'string', description: 'Project name' },
- },
- team: {
- id: { type: 'string', description: 'Team ID' },
- },
- user: {
- id: { type: 'string', description: 'User ID' },
- },
- target: {
- type: 'string',
- description: 'Deployment target (production, preview)',
- },
- plan: {
- type: 'string',
- description: 'Account plan type',
- },
- domain: {
- name: { type: 'string', description: 'Domain name' },
- },
+ ...linksOutputs,
+ ...deploymentResourceOutputs,
+ project: deploymentOutputs.project,
+ team: deploymentOutputs.team,
+ user: deploymentOutputs.user,
+ ...deploymentTargetPlanDomain,
} as Record
}
diff --git a/apps/sim/triggers/vercel/webhook.ts b/apps/sim/triggers/vercel/webhook.ts
index dbe7868ff59..e4c0a00634f 100644
--- a/apps/sim/triggers/vercel/webhook.ts
+++ b/apps/sim/triggers/vercel/webhook.ts
@@ -9,21 +9,24 @@ import {
} from '@/triggers/vercel/utils'
/**
- * Generic Vercel Webhook Trigger
- * Captures all Vercel webhook events
+ * Vercel webhook trigger for a curated bundle of frequent event types.
+ * Vercel requires an explicit event list; this is not every event in their catalog.
*/
export const vercelWebhookTrigger: TriggerConfig = {
id: 'vercel_webhook',
- name: 'Vercel Webhook (All Events)',
+ name: 'Vercel Webhook (Common Events)',
provider: 'vercel',
- description: 'Trigger workflow on any Vercel webhook event',
+ description:
+ 'Trigger on a curated set of common Vercel events (deployments, projects, domains, edge config). Pick a specific trigger to listen to one event type only.',
version: '1.0.0',
icon: VercelIcon,
subBlocks: buildTriggerSubBlocks({
triggerId: 'vercel_webhook',
triggerOptions: vercelTriggerOptions,
- setupInstructions: vercelSetupInstructions('All Events'),
+ setupInstructions: vercelSetupInstructions(
+ 'common deployment, project, domain, and edge-config events'
+ ),
extraFields: buildVercelExtraFields('vercel_webhook'),
}),
diff --git a/apps/sim/triggers/zoom/utils.ts b/apps/sim/triggers/zoom/utils.ts
index 20e316f0400..1b95df47ace 100644
--- a/apps/sim/triggers/zoom/utils.ts
+++ b/apps/sim/triggers/zoom/utils.ts
@@ -26,7 +26,12 @@ export function isZoomEventMatch(triggerId: string, event: string): boolean {
return false
}
- return allowedEvents.includes(event)
+ const ev = event?.trim()
+ if (!ev) {
+ return false
+ }
+
+ return allowedEvents.includes(ev)
}
/**
@@ -64,7 +69,7 @@ export function zoomSetupInstructions(eventType: ZoomEventType): string {
const instructions = [
'Copy the Webhook URL above.',
- 'Go to the Zoom Marketplace and open your app (or create a new Webhook Only app).',
+ 'Go to the Zoom Marketplace and create or open a Webhook-only or general app with Event Subscriptions enabled (Meeting / Recording events as needed). Admin approval may be required for account-level webhooks.',
"Copy the Secret Token from your Zoom app's Features page and paste it in the Secret Token field above.",
'Click "Save Configuration" above to activate the trigger.',
'Navigate to Features > Event Subscriptions and click Add Event Subscription.',
@@ -123,23 +128,45 @@ export function buildMeetingOutputs(): Record {
},
object: {
type: 'object',
- description: 'Meeting details',
- properties: {
- id: { type: 'number', description: 'Meeting ID' },
- uuid: { type: 'string', description: 'Meeting UUID' },
- topic: { type: 'string', description: 'Meeting topic' },
- meeting_type: {
- type: 'number',
- description: 'Meeting type (1=instant, 2=scheduled, etc.)',
- },
- host_id: { type: 'string', description: 'Host user ID' },
- start_time: { type: 'string', description: 'Meeting start time (ISO 8601)' },
- end_time: {
- type: 'string',
- description: 'Meeting end time (ISO 8601, present on meeting.ended)',
- },
- timezone: { type: 'string', description: 'Meeting timezone' },
- duration: { type: 'number', description: 'Meeting duration in minutes' },
+ description: 'Meeting details (shape aligns with Zoom Meetings webhook object fields)',
+ id: { type: 'number', description: 'Meeting ID' },
+ uuid: { type: 'string', description: 'Meeting UUID' },
+ topic: { type: 'string', description: 'Meeting topic' },
+ meeting_type: {
+ type: 'number',
+ description: 'Meeting type (1=instant, 2=scheduled, etc.; maps to Zoom `type`)',
+ },
+ host_id: { type: 'string', description: 'Host user ID' },
+ host_email: {
+ type: 'string',
+ description: 'Host email address (when provided by Zoom)',
+ },
+ start_time: { type: 'string', description: 'Meeting start time (ISO 8601)' },
+ end_time: {
+ type: 'string',
+ description: 'Meeting end time (ISO 8601, present on meeting.ended)',
+ },
+ timezone: { type: 'string', description: 'Meeting timezone' },
+ duration: { type: 'number', description: 'Meeting duration in minutes' },
+ agenda: {
+ type: 'string',
+ description: 'Meeting agenda or description (when provided)',
+ },
+ join_url: {
+ type: 'string',
+ description: 'URL for participants to join (when provided)',
+ },
+ password: {
+ type: 'string',
+ description: 'Meeting password (when provided)',
+ },
+ status: {
+ type: 'string',
+ description: 'Meeting status (e.g. waiting, started; when provided)',
+ },
+ created_at: {
+ type: 'string',
+ description: 'Creation timestamp in ISO 8601 format (when provided)',
},
},
},
@@ -166,26 +193,30 @@ export function buildParticipantOutputs(): Record {
},
object: {
type: 'object',
- description: 'Meeting details',
- properties: {
- id: { type: 'number', description: 'Meeting ID' },
- uuid: { type: 'string', description: 'Meeting UUID' },
- topic: { type: 'string', description: 'Meeting topic' },
- host_id: { type: 'string', description: 'Host user ID' },
- participant: {
- type: 'object',
- description: 'Participant details',
- properties: {
- id: { type: 'string', description: 'Participant user ID' },
- user_id: { type: 'string', description: 'Participant user ID (16-digit)' },
- user_name: { type: 'string', description: 'Participant display name' },
- email: { type: 'string', description: 'Participant email' },
- join_time: { type: 'string', description: 'Time participant joined (ISO 8601)' },
- leave_time: {
- type: 'string',
- description: 'Time participant left (ISO 8601, present on participant_left)',
- },
- },
+ description: 'Meeting and participant details',
+ id: { type: 'number', description: 'Meeting ID' },
+ uuid: { type: 'string', description: 'Meeting UUID' },
+ topic: { type: 'string', description: 'Meeting topic' },
+ host_id: { type: 'string', description: 'Host user ID' },
+ join_url: {
+ type: 'string',
+ description: 'URL for participants to join (when provided)',
+ },
+ participant: {
+ type: 'object',
+ description: 'Participant details',
+ id: { type: 'string', description: 'Participant identifier' },
+ user_id: { type: 'string', description: 'Participant user ID (when a Zoom user)' },
+ user_name: { type: 'string', description: 'Participant display name' },
+ email: { type: 'string', description: 'Participant email (when available)' },
+ join_time: { type: 'string', description: 'Time participant joined (ISO 8601)' },
+ leave_time: {
+ type: 'string',
+ description: 'Time participant left (ISO 8601, present on participant_left)',
+ },
+ duration: {
+ type: 'number',
+ description: 'Seconds the participant was in the meeting (when provided)',
},
},
},
@@ -213,22 +244,30 @@ export function buildRecordingOutputs(): Record {
},
object: {
type: 'object',
- description: 'Recording details',
- properties: {
- id: { type: 'number', description: 'Meeting ID' },
- uuid: { type: 'string', description: 'Meeting UUID' },
- topic: { type: 'string', description: 'Meeting topic' },
- host_id: { type: 'string', description: 'Host user ID' },
- host_email: { type: 'string', description: 'Host email' },
- start_time: { type: 'string', description: 'Recording start time (ISO 8601)' },
- duration: { type: 'number', description: 'Recording duration in minutes' },
- total_size: { type: 'number', description: 'Total recording size in bytes' },
- recording_count: { type: 'number', description: 'Number of recording files' },
- share_url: { type: 'string', description: 'URL to share the recording' },
- recording_files: {
- type: 'json',
- description: 'Array of recording file objects with download URLs',
- },
+ description: 'Cloud recording details (aligns with Zoom cloud recording objects)',
+ id: { type: 'number', description: 'Meeting ID' },
+ uuid: { type: 'string', description: 'Meeting UUID' },
+ topic: { type: 'string', description: 'Meeting topic' },
+ meeting_type: {
+ type: 'number',
+ description: 'Meeting type (when provided; maps to Zoom `type`)',
+ },
+ host_id: { type: 'string', description: 'Host user ID' },
+ host_email: { type: 'string', description: 'Host email' },
+ start_time: { type: 'string', description: 'Recording start time (ISO 8601)' },
+ timezone: { type: 'string', description: 'Meeting timezone (when provided)' },
+ agenda: {
+ type: 'string',
+ description: 'Meeting agenda (when provided)',
+ },
+ duration: { type: 'number', description: 'Recording duration in minutes' },
+ total_size: { type: 'number', description: 'Total recording size in bytes' },
+ recording_count: { type: 'number', description: 'Number of recording files' },
+ share_url: { type: 'string', description: 'URL to share the recording' },
+ recording_files: {
+ type: 'json',
+ description:
+ 'Array of recording file objects (e.g. id, file_type, play_url, download_url) per Zoom cloud recording payloads',
},
},
},