fix(webhooks): harden audited provider triggers (#3997)

* fix(triggers): apply webhook audit follow-ups

Align the Greenhouse webhook matcher with provider conventions and clarify the Notion webhook secret setup text after the audit review.

Made-with: Cursor

* fix(webhooks): Salesforce provider handler, Zoom CRC and block wiring

Add salesforce WebhookProviderHandler with required shared secret auth,
matchEvent filtering, formatInput aligned to trigger outputs, and
idempotency keys. Require webhook secret and document JSON-only Flow
setup; enforce objectType when configured.

Zoom: pass raw body into URL validation signature check, try all active
webhooks on a path for secret match, add extractIdempotencyId, tighten
event matching for specialized triggers. Wire Zoom triggers into the
Zoom block. Extend handleChallenge with optional rawBody.

Register Salesforce pending verification probes for pre-save URL checks.

* fix(webhooks): harden Resend and Linear triggers (idempotency, auth, outputs)

- Dedupe Resend deliveries via svix-id and Linear via Linear-Delivery in idempotency keys
- Require Resend signing secret; validate createSubscription id and signing_secret
- Single source for Resend event maps in triggers/utils; fail closed on unknown trigger IDs
- Add raw event data to Resend trigger outputs and formatInput
- Linear: remove body-based idempotency key; timestamp skew after HMAC verify; format url and actorType
- Tighten isLinearEventMatch for unknown triggers; clarify generic webhook copy; fix header examples
- Add focused tests for idempotency headers and Linear matchEvent

* fix(webhooks): harden Vercel and Greenhouse trigger handlers

Require Vercel signing secret and validate x-vercel-signature; add
matchEvent with dynamic import, delivery idempotency, strict
createSubscription trigger IDs, and formatInput aligned to string IDs.

Greenhouse: dynamic import in matchEvent, strict unknown trigger IDs,
Greenhouse-Event-ID idempotency header, body fallback keys, clearer
optional secret copy. Update generic trigger wording and add tests.

* fix(gong): JWT verification, trigger UX, alignment script

- Optional RS256 verification when Gong JWT public key is configured (webhook_url + body_sha256 per Gong docs); URL secrecy when unset.
- Document that Gong rules filter calls; payload has no event type; add eventType + callId outputs for discoverability.
- Refactor Gong triggers to buildTriggerSubBlocks + shared JWT field; setup copy matches security model.
- Add check-trigger-alignment.ts (Gong bundled; extend PROVIDER_CHECKS for others) and update add-trigger guidance paths.

Made-with: Cursor

* fix(notion): align webhook lifecycle and outputs

Handle Notion verification requests safely, expose the documented webhook fields in the trigger contract, and update setup guidance so runtime data and user-facing configuration stay aligned.

Made-with: Cursor

* fix(webhooks): tighten remaining provider hardening

Close the remaining pre-merge caveats by tightening Salesforce, Zoom, and Linear behavior, and follow through on the deferred provider and tooling cleanup for Vercel, Greenhouse, Gong, and Notion.

Made-with: Cursor

* refactor(webhooks): move subscription helpers out of providers

Move provider subscription helpers alongside the subscription lifecycle module and add targeted TSDoc so the file placement matches the responsibility boundaries in the webhook architecture.

Made-with: Cursor

* fix(zoom): resolve env-backed secrets during validation

Use the same env-aware secret resolution path for Zoom endpoint validation as regular delivery verification so URL validation works correctly when the secret token is stored via env references.

Made-with: Cursor

* fix build

* consolidate tests

* refactor(salesforce): share payload object type parsing

Remove dead code in the Salesforce provider and move shared object-type extraction into a single helper so trigger matching and input shaping stay in sync.

Made-with: Cursor

* fix(webhooks): address remaining review follow-ups

Loosen Linear's replay window to better tolerate delayed retries and make Notion event mismatches return false consistently with the rest of the hardened providers.

Made-with: Cursor

* test(webhooks): separate Zoom coverage and clean Notion output shape

Move Zoom provider coverage into its own test file and strip undeclared Notion type fields from normalized output objects so the runtime shape better matches the trigger contract.

Made-with: Cursor

* feat(triggers): enrich Vercel and Greenhouse webhook output shapes

Document and pass through Vercel links, regions, deployment.meta, and
domain.delegated; add top-level Greenhouse applicationId, candidateId,
and jobId aligned with webhook common attributes. Extend alignment checker
for greenhouse, update provider docs, and add formatInput tests.

Made-with: Cursor

* feat(webhooks): enrich Resend trigger outputs; clarify Notion output docs

- Resend: expose broadcast_id, template_id, tags, and data_created_at from
  payload data (per Resend webhook docs); keep alignment with formatInput.
- Add resend entry to check-trigger-alignment and unit test for formatInput.
- Notion: tighten output descriptions for authors, entity types, parent types,
  attempt_number, and accessible_by per Notion webhooks event reference.

Made-with: Cursor

* feat(webhooks): enrich Zoom and Gong trigger output schemas

- Zoom: add formatInput passthrough, fix nested TriggerOutput shape (drop invalid `properties` wrappers), document host_email, join_url, agenda, status, meeting_type on recordings, participant duration, and alignment checker entry.
- Gong: flatten topics/highlights from callData.content in formatInput, extend metaData and trigger outputs per API docs, tests and alignment keys updated.
- Docs: add English webhook trigger sections for Zoom and Gong tools pages.

* feat(triggers): enrich Salesforce and Linear webhook output schemas

Salesforce: expose simEventType alongside eventType; pass OwnerId and
SystemModstamp on record lifecycle inputs; add AccountId/OwnerId for
Opportunity and AccountId/ContactId/OwnerId for Case. Align trigger
output docs with Flow JSON payloads and formatInput.

Linear: document actor email and profile url per official webhook
payload; add Comment data.edited from Linear's sample payload.

Tests: extend Salesforce formatInput coverage for new fields.

* remove from mdx

* chore(webhooks): expand trigger alignment coverage

Extend the trigger alignment checker to cover additional webhook providers so output contracts are verified across more of the recently added trigger surface.

Made-with: Cursor

* updated skills

* updated file naming semantics

* rename file
This commit is contained in:
Waleed
2026-04-06 20:30:54 -07:00
committed by GitHub
parent 89ae738745
commit 64c6cd973f
65 changed files with 3621 additions and 1626 deletions
+164 -514
View File
@@ -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 <strong>Webhook URL</strong> above',
'Go to <strong>{Service} Settings > Webhooks</strong>',
'Click <strong>Add Webhook</strong>',
'Paste the webhook URL',
`Select the <strong>${eventType}</strong> event type`,
'Save the webhook configuration',
'Paste the webhook URL and save',
'Click "Save" above to activate your trigger',
]
return instructions
.map((instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
@@ -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<string, TriggerOutput> {
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}'
import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}'
// Add to TRIGGER_REGISTRY
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)
## Provider Handler
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 provider-specific webhook logic lives in a single handler file: `apps/sim/lib/webhooks/providers/{service}.ts`.
### When to Use Automatic Registration
### When to Create a Handler
Check the service's API documentation for endpoints like:
- `POST /webhooks` or `POST /hooks` - Create webhook
- `DELETE /webhooks/{id}` - Delete webhook
| 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 |
Services that support this pattern include: Grain, Lemlist, Calendly, Airtable, Webflow, Typeform, etc.
If none apply, you don't need a handler. The default handler provides bearer token auth.
### Implementation Steps
#### 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
export const {service}Handler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'X-{Service}-Signature',
validateFn: validate{Service}Signature,
providerLabel: '{Service}',
}),
Change instructions to indicate automatic webhook creation:
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<string, unknown>)) return false
}
return true
},
```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 <strong>Settings > API</strong>.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in {Service} for <strong>${eventType}</strong> events.`,
'The webhook will be automatically deleted when you remove this trigger.',
]
return instructions
.map((instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.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,
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
return {
input: {
eventType: b.type,
resourceId: (b.data as Record<string, unknown>)?.id || '',
resource: b.data,
},
requestId
)
if (result) {
// Update the webhook record with the external webhook ID
const updatedConfig = {
...(savedWebhook.providerConfig as Record<string, any>),
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 }
)
}
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
return obj.id && obj.type ? `${obj.type}:${obj.id}` : null
},
}
// --- End {Service} specific logic ---
```
Then add the helper function at the end of the file:
### Register the Handler
In `apps/sim/lib/webhooks/providers/registry.ts`:
```typescript
async function create{Service}WebhookSubscription(
webhookData: any,
requestId: string
): Promise<{ id: string } | undefined> {
try {
const { path, providerConfig } = webhookData
const { apiKey, triggerId, projectId } = providerConfig || {}
import { {service}Handler } from '@/lib/webhooks/providers/{service}'
if (!apiKey) {
throw new Error('{Service} API Key is required.')
}
const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
// ... existing (alphabetical) ...
{service}: {service}Handler,
}
```
// Map trigger IDs to service event types
const eventTypeMap: Record<string, string | undefined> = {
{service}_event_a: 'eventA',
{service}_event_b: 'eventB',
{service}_webhook: undefined, // Generic - no filter
}
## Output Alignment (Critical)
const eventType = eventTypeMap[triggerId]
const notificationUrl = `${getBaseUrl()}/api/webhooks/trigger/${path}`
There are two sources of truth that **MUST be aligned**:
const requestBody: Record<string, any> = {
url: notificationUrl,
}
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
if (eventType) {
requestBody.eventType = eventType
}
If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover.
if (projectId) {
requestBody.projectId = projectId
}
**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
const response = await fetch('https://api.{service}.com/webhooks', {
## Automatic Webhook Registration
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 { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types'
export const {service}Handler: WebhookProviderHandler = {
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
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(requestBody),
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ url: getNotificationUrl(ctx.webhook) }),
})
const responseBody = await response.json()
if (!res.ok) throw new Error(`{Service} error: ${res.status}`)
const { id } = (await res.json()) as { id: string }
return { providerConfigUpdates: { externalId: id } }
},
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
}
}
```
#### 4. Add Webhook Deletion to Provider Subscriptions
In `apps/sim/lib/webhooks/provider-subscriptions.ts`:
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<void> {
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}`, {
async deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
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}`,
},
})
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)
}
headers: { Authorization: `Bearer ${apiKey}` },
}).catch(() => {})
},
}
```
3. Add to `cleanupExternalWebhook`:
```typescript
export async function cleanupExternalWebhook(...): Promise<void> {
// ... existing providers ...
} else if (webhook.provider === '{service}') {
await delete{Service}Webhook(webhook, requestId)
}
}
```
**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`
### 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
## 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
## Trigger Outputs & Webhook Input Formatting
### Important: Two Sources of Truth
There are two related but separate concerns:
1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown.
2. **`formatWebhookInput`** - Implementation that transforms raw webhook payload into actual data. Located in `apps/sim/lib/webhooks/utils.server.ts`.
**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
- **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.
### Adding a Handler
In `apps/sim/lib/webhooks/utils.server.ts`, add a handler block:
```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,
}
}
```
**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
### 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<string, TriggerOutput> {
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<string, TriggerOutput> {
}
```
## Generic Webhook Trigger Pattern
## Checklist
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
### 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
+212
View File
@@ -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
+143 -611
View File
@@ -3,63 +3,57 @@ description: Create webhook triggers for a Sim integration using the generic tri
argument-hint: <service-name>
---
# 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 <strong>Webhook URL</strong> above',
'Go to <strong>{Service} Settings > Webhooks</strong>',
'Click <strong>Add Webhook</strong>',
'Paste the webhook URL',
`Select the <strong>${eventType}</strong> event type`,
'Save the webhook configuration',
'Paste the webhook URL and save',
'Click "Save" above to activate your trigger',
]
return instructions
.map((instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
@@ -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<string, TriggerOutput> {
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}'
import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}'
// Add to TRIGGER_REGISTRY
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)
## Provider Handler
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 <strong>Settings > API</strong>.',
`Click <strong>"Save Configuration"</strong> to automatically create the webhook in {Service} for <strong>${eventType}</strong> events.`,
'The webhook will be automatically deleted when you remove this trigger.',
]
return instructions
.map((instruction, index) =>
`<div class="mb-3"><strong>${index + 1}.</strong> ${instruction}</div>`
)
.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<SubscriptionResult | undefined> {
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<string, string | undefined> = {
{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<string, unknown> = {
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<string, unknown>
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<void> {
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
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<string, unknown>`
- `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 |
| 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 |
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`.
If none apply, you don't need a handler. The default handler provides bearer token auth.
### Simple Example: HMAC Auth Only
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,130 +208,117 @@ 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<string, unknown>
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<string, unknown>)) return false
}
return true
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
if (obj.id && obj.type) {
return `${obj.type}:${obj.id}`
}
return null
},
}
```
### Registering the Handler
In `apps/sim/lib/webhooks/providers/registry.ts`:
```typescript
import { {service}Handler } from '@/lib/webhooks/providers/{service}'
const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
// ... existing providers (alphabetical) ...
{service}: {service}Handler,
}
```
## Trigger Outputs & Webhook Input Formatting
### Important: Two Sources of Truth
There are two related but separate concerns:
1. **Trigger `outputs`** - Schema/contract defining what fields SHOULD be available. Used by UI for tag dropdown.
2. **`formatInput` on the handler** - Implementation that transforms raw webhook payload into actual data. Defined in `apps/sim/lib/webhooks/providers/{service}.ts`.
**These MUST be aligned.** The fields returned by `formatInput` should match what's defined in trigger `outputs`. If they differ:
- Tag dropdown shows fields that don't exist (broken variable resolution)
- Or actual data has fields not shown in dropdown (users can't discover them)
### When to Add `formatInput`
- **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`:
```typescript
import type {
FormatInputContext,
FormatInputResult,
WebhookProviderHandler,
} from '@/lib/webhooks/providers/types'
export const {service}Handler: WebhookProviderHandler = {
// ... other methods ...
async formatInput({ body }: FormatInputContext): Promise<FormatInputResult> {
const b = body as Record<string, unknown>
return {
input: {
eventType: b.type,
resourceId: (b.data as Record<string, unknown>)?.id || '',
timestamp: b.created_at,
resource: b.data,
},
}
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
return obj.id && obj.type ? `${obj.type}:${obj.id}` : null
},
}
```
**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
### Register the Handler
### Verify Alignment
In `apps/sim/lib/webhooks/providers/registry.ts`:
Run the alignment checker:
```bash
bunx scripts/check-trigger-alignment.ts {service}
```typescript
import { {service}Handler } from '@/lib/webhooks/providers/{service}'
const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
// ... existing (alphabetical) ...
{service}: {service}Handler,
}
```
## Trigger Outputs
## Output Alignment (Critical)
There are two sources of truth that **MUST be aligned**:
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
If they differ: the tag dropdown shows fields that don't exist, or actual data has fields users can't discover.
**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
## Automatic Webhook Registration
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 { getNotificationUrl, getProviderConfig } from '@/lib/webhooks/provider-subscription-utils'
import type { DeleteSubscriptionContext, SubscriptionContext, SubscriptionResult } from '@/lib/webhooks/providers/types'
export const {service}Handler: WebhookProviderHandler = {
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
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 deleteSubscription(ctx: DeleteSubscriptionContext): Promise<void> {
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 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`
## 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<string, TriggerOutput> {
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<string, TriggerOutput> {
}
```
## Generic Webhook Trigger Pattern
## Checklist
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
### 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
+212
View File
@@ -0,0 +1,212 @@
---
description: Validate an existing Sim webhook trigger against provider API docs and repository conventions
argument-hint: <service-name> [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
+1 -1
View File
@@ -284,7 +284,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linear_v2: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
loops: LoopsIcon,
+1 -1
View File
@@ -6,7 +6,7 @@ description: Interact with Linear issues, projects, and more
import { BlockInfoCard } from "@/components/ui/block-info-card"
<BlockInfoCard
type="linear"
type="linear_v2"
color="#5E6AD2"
/>
@@ -284,7 +284,7 @@ export const blockTypeToIconMap: Record<string, IconComponent> = {
langsmith: LangsmithIcon,
launchdarkly: LaunchDarklyIcon,
lemlist: LemlistIcon,
linear: LinearIcon,
linear_v2: LinearIcon,
linkedin: LinkedInIcon,
linkup: LinkupIcon,
loops: LoopsIcon,
@@ -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 <code>data</code> 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,
@@ -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
}
+18
View File
@@ -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<ZoomResponse> = {
type: 'zoom',
@@ -17,6 +18,17 @@ export const ZoomBlock: BlockConfig<ZoomResponse> = {
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: [
@@ -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')
})
})
+4 -1
View File
@@ -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}`
@@ -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 {
+3 -2
View File
@@ -123,12 +123,13 @@ export async function handleProviderChallenges(
body: unknown,
request: NextRequest,
requestId: string,
path: string
path: string,
rawBody?: string
): Promise<NextResponse | null> {
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
}
@@ -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<string, unknown>): Record<string, unknown> {
return (webhook.providerConfig as Record<string, unknown>) || {}
}
/** Build the public callback URL providers should deliver webhook events to. */
export function getNotificationUrl(webhook: Record<string, unknown>): 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
@@ -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<string, unknown>,
+1 -1
View File
@@ -8,7 +8,7 @@ import {
getCredentialOwner,
getNotificationUrl,
getProviderConfig,
} from '@/lib/webhooks/providers/subscription-utils'
} from '@/lib/webhooks/provider-subscription-utils'
import type {
DeleteSubscriptionContext,
FormatInputContext,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
@@ -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<string, unknown>).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<string, unknown>
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)
})
})
+124
View File
@@ -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<NextResponse | null> {
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<FormatInputResult> {
const b = body as Record<string, unknown>
const callData = b.callData as Record<string, unknown> | undefined
const metaData = (callData?.metaData as Record<string, unknown>) || {}
const content = callData?.content as Record<string, unknown> | 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,
},
}
},
+1 -1
View File
@@ -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,
+71 -6
View File
@@ -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<FormatInputResult> {
const b = body as Record<string, unknown>
const payload = (b.payload || {}) as Record<string, unknown>
const application = (payload.application || {}) as Record<string, unknown>
const candidate = (application.candidate || {}) as Record<string, unknown>
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<string, unknown>).id === 'number'
) {
jobId = (jobNested as Record<string, unknown>).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<string, unknown>
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<string, unknown>
const action = typeof b.action === 'string' ? b.action : ''
const payload = (b.payload || {}) as Record<string, unknown>
const application = (payload.application || {}) as Record<string, unknown>
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<string, unknown>
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<string, unknown>
const jobId = job.id
if (jobId !== undefined && jobId !== null && jobId !== '') {
return `greenhouse:${action}:job:${String(jobId)}`
}
return null
},
}
+1 -1
View File
@@ -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,
@@ -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()
})
})
+74 -18
View File
@@ -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<NextResponse | null> {
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<string, unknown>
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<FormatInputResult> {
const b = body as Record<string, unknown>
const rawActor = b.actor
let actor: unknown = null
if (rawActor && typeof rawActor === 'object' && !Array.isArray(rawActor)) {
const a = rawActor as Record<string, unknown>
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<string, unknown>
const data = obj.data as Record<string, unknown> | undefined
if (obj.action && data?.id) {
return `${obj.action}:${data.id}`
}
return null
},
}
@@ -15,7 +15,7 @@ import {
getCredentialOwner,
getNotificationUrl,
getProviderConfig,
} from '@/lib/webhooks/providers/subscription-utils'
} from '@/lib/webhooks/provider-subscription-utils'
import type {
AuthContext,
DeleteSubscriptionContext,
@@ -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')
})
})
+56 -5
View File
@@ -53,21 +53,61 @@ export const notionHandler: WebhookProviderHandler = {
providerLabel: 'Notion',
}),
handleReachabilityTest(body: unknown, requestId: string) {
const obj = body as Record<string, unknown> | 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<FormatInputResult> {
const b = body as Record<string, unknown>
const rawEntity =
b.entity && typeof b.entity === 'object' ? (b.entity as Record<string, unknown>) : {}
const rawData = b.data && typeof b.data === 'object' ? (b.data as Record<string, unknown>) : {}
const rawParent =
rawData.parent && typeof rawData.parent === 'object'
? (rawData.parent as Record<string, unknown>)
: 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<string, unknown>
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
},
}
@@ -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<string, WebhookProviderHandler> = {
notion: notionHandler,
outlook: outlookHandler,
rss: rssHandler,
salesforce: salesforceHandler,
slack: slackHandler,
stripe: stripeHandler,
telegram: telegramHandler,
@@ -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 <onboarding@resend.dev>',
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',
})
})
})
+51 -46
View File
@@ -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<NextResponse | null> {
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<string, string> = {
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<string, unknown>)?.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<string, unknown> | undefined
const bounce = data?.bounce as Record<string, unknown> | undefined
const click = data?.click as Record<string, unknown> | 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<string, string[]> = {
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) {
@@ -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<string, string>): 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<string, unknown>
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<string, unknown>
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')
})
})
@@ -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, unknown>
): 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<string, unknown> | 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<string, unknown>
if (typeof r.sobjectType === 'string') {
return r.sobjectType
}
const ra = r.attributes as Record<string, unknown> | 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<string, unknown> {
return body && typeof body === 'object' && !Array.isArray(body)
? (body as Record<string, unknown>)
: {}
}
function extractRecordCore(body: Record<string, unknown>): Record<string, unknown> {
const nested = body.record
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
return { ...(nested as Record<string, unknown>) }
}
const skip = new Set([
'eventType',
'simEventType',
'changedFields',
'previousStage',
'newStage',
'previousStatus',
'newStatus',
'payload',
'record',
])
const out: Record<string, unknown> = {}
for (const [k, v] of Object.entries(body)) {
if (!skip.has(k)) {
out[k] = v
}
}
return out
}
function pickTimestamp(body: Record<string, unknown>, record: Record<string, unknown>): 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<string, unknown>)
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
.join(',')}}`
}
return JSON.stringify(value)
}
function buildFallbackDeliveryFingerprint(body: Record<string, unknown>): string {
return crypto.createHash('sha256').update(stableSerialize(body), 'utf8').digest('hex')
}
function pickRecordId(body: Record<string, unknown>, record: Record<string, unknown>): 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<string, unknown>, 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<FormatInputResult> {
const rawPc = (ctx.webhook as { providerConfig?: unknown }).providerConfig
const pc =
rawPc && typeof rawPc === 'object' && !Array.isArray(rawPc)
? (rawPc as Record<string, unknown>)
: {}
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<string, unknown>).type === 'string'
? String((record.attributes as Record<string, unknown>).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)}`
},
}
+1 -1
View File
@@ -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,
+1 -1
View File
@@ -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,
+5 -3
View File
@@ -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<FormatInputResult>
/** 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> | NextResponse | null
}
@@ -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()
})
})
})
+184 -51
View File
@@ -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<string, unknown>
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<string, unknown>)?.id
if (id === undefined || id === null || id === '') {
return null
}
return `vercel:${String(id)}`
},
async createSubscription(ctx: SubscriptionContext): Promise<SubscriptionResult | undefined> {
const { webhook, requestId } = ctx
@@ -40,25 +90,24 @@ export const vercelHandler: WebhookProviderHandler = {
)
}
const eventTypeMap: Record<string, string[] | undefined> = {
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<string, unknown> = {
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<string, unknown>
const payload = (body.payload || {}) as Record<string, unknown>
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<string, unknown>
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<string, unknown> | null = null
if (deployment && typeof deployment === 'object') {
const meta = (deployment as Record<string, unknown>).meta
if (meta && typeof meta === 'object' && !Array.isArray(meta)) {
deploymentMeta = meta as Record<string, unknown>
}
}
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<string, unknown>).id != null
? String((deployment as Record<string, unknown>).id)
: '',
url: ((deployment as Record<string, unknown>).url as string) ?? '',
name: ((deployment as Record<string, unknown>).name as string) ?? '',
meta: deploymentMeta,
}
: null,
project:
project && typeof project === 'object'
? {
id:
(project as Record<string, unknown>).id != null
? String((project as Record<string, unknown>).id)
: '',
name: ((project as Record<string, unknown>).name as string) ?? '',
}
: null,
team:
team && typeof team === 'object'
? {
id:
(team as Record<string, unknown>).id != null
? String((team as Record<string, unknown>).id)
: '',
}
: null,
user:
user && typeof user === 'object'
? {
id:
(user as Record<string, unknown>).id != null
? String((user as Record<string, unknown>).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<string, unknown>).name as string) ?? '',
delegated:
typeof (domain as Record<string, unknown>).delegated === 'boolean'
? ((domain as Record<string, unknown>).delegated as boolean)
: null,
}
: null,
},
}
},
+1 -1
View File
@@ -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,
@@ -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<string, string>): 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)
})
})
+131 -42
View File
@@ -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=<hex>` 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<Array<{ secretToken: string }>> {
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<string, unknown>)
: {}
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<FormatInputResult> {
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<string, unknown>
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<string, unknown> | undefined
const inner = payload?.object as Record<string, unknown> | undefined
const participant =
inner?.participant &&
typeof inner.participant === 'object' &&
!Array.isArray(inner.participant)
? (inner.participant as Record<string, unknown>)
: 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<string, unknown>
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<string, unknown> | 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<string, unknown> | null
secretToken = (config?.secretToken as string) || ''
}
} catch (err) {
logger.warn(`[${requestId}] Failed to look up webhook secret for Zoom validation`, err)
return null
}
if (!secretToken) {
logger.warn(
`[${requestId}] No secret token configured for Zoom URL validation on path: ${path}`
)
return null
}
// Verify the challenge request's signature to prevent HMAC oracle attacks
const signature = request.headers.get('x-zm-signature')
const timestamp = request.headers.get('x-zm-request-timestamp')
if (!signature || !timestamp) {
logger.warn(`[${requestId}] Zoom challenge request missing signature headers — rejecting`)
return null
}
const rawBody = JSON.stringify(body)
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
},
}
+15 -42
View File
@@ -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(),
+71 -12
View File
@@ -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 <strong>Webhook URL</strong> above.',
'In Gong, go to <strong>Admin center > Settings > Ecosystem > Automation rules</strong>.',
'Click <strong>"+ Add Rule"</strong> to create a new automation rule.',
`Configure rule filters to match <strong>${eventType}</strong> calls.`,
`Configure rule filters in Gong for the calls you want (e.g. <strong>${eventType}</strong>). Gong does not send a separate event name in the JSON payload — filtering happens in the rule.`,
'Under Actions, select <strong>"Fire webhook"</strong>.',
'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.',
'<strong>Authentication:</strong> Use either <strong>URL includes key</strong> (recommended default — Sim’s URL is already secret) or <strong>Signed JWT header</strong>. If you use Signed JWT, paste Gong’s public key into the field above so Sim can verify the <code>Authorization</code> token.',
'Save the rule and click <strong>"Save"</strong> above to activate your trigger.',
]
return instructions
.map(
(instruction, index) =>
`<div class="mb-3">${index === 0 ? instruction : `<strong>${index}.</strong> ${instruction}`}</div>`
)
.map((instruction, index) => {
if (index === 0) {
return `<div class="mb-3">${instruction}</div>`
}
return `<div class="mb-3"><strong>${index}.</strong> ${instruction}</div>`
})
.join('')
}
@@ -38,6 +62,15 @@ export function gongSetupInstructions(eventType: string): string {
*/
export function buildCallOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
},
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<string, TriggerOutput>
}
+14 -50
View File
@@ -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(),
@@ -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<string, unknown>).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<string, unknown>).jobId).toBe(66)
expect((jobFromOffer.input as Record<string, unknown>).applicationId).toBe(2)
})
})
+49 -18
View File
@@ -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<string, string> = {
*/
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<string, TriggerOutput> {
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 <strong>Configure &gt; Dev Center &gt; Webhooks</strong>.',
'Click <strong>Create New Webhook</strong>.',
'Paste the Webhook URL into the <strong>Endpoint URL</strong> field.',
'Enter a <strong>Secret Key</strong> for signature verification (optional).',
`Under <strong>When</strong>, select the <strong>${eventType}</strong> event.`,
'Enter a <strong>Secret Key</strong> for HMAC signature verification (recommended). Leave empty only if you accept unauthenticated POSTs to this URL.',
`Under <strong>When</strong>, select the appropriate <strong>${eventType}</strong>.`,
'Click <strong>Create Webhook</strong> to save.',
'Click "Save" above to activate your trigger.',
]
@@ -91,6 +123,7 @@ export function greenhouseSetupInstructions(eventType: string): string {
export function buildCandidateHiredOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
export function buildNewApplicationOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
export function buildCandidateStageChangeOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput
phone_numbers: { type: 'json', description: 'Phone numbers' },
},
jobs: { type: 'json', description: 'Associated jobs (array)' },
source: {
id: { type: 'number', description: 'Source ID' },
public_name: { type: 'string', description: 'Source name' },
},
source: buildSourceOutputs(),
custom_fields: { type: 'json', description: 'Application custom fields' },
},
},
@@ -218,6 +244,7 @@ export function buildCandidateStageChangeOutputs(): Record<string, TriggerOutput
export function buildCandidateRejectedOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput> {
export function buildOfferCreatedOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput> {
export function buildJobCreatedOutputs(): Record<string, TriggerOutput> {
return {
action: { type: 'string', description: 'The webhook event type (job_created)' },
...greenhouseIndexedOutputs,
payload: { job: buildJobPayload() },
} as Record<string, TriggerOutput>
}
@@ -311,6 +340,7 @@ export function buildJobCreatedOutputs(): Record<string, TriggerOutput> {
export function buildJobUpdatedOutputs(): Record<string, TriggerOutput> {
return {
action: { type: 'string', description: 'The webhook event type (job_updated)' },
...greenhouseIndexedOutputs,
payload: { job: buildJobPayload() },
} as Record<string, TriggerOutput>
}
@@ -321,6 +351,7 @@ export function buildJobUpdatedOutputs(): Record<string, TriggerOutput> {
export function buildWebhookOutputs(): Record<string, TriggerOutput> {
return {
action: { type: 'string', description: 'The webhook event type' },
...greenhouseIndexedOutputs,
payload: { type: 'json', description: 'Full event payload' },
} as Record<string, TriggerOutput>
}
+6 -6
View File
@@ -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'),
}),
+20
View File
@@ -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)
})
})
+46 -5
View File
@@ -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<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
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
+9 -4
View File
@@ -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 <code>type</code> and <code>action</code> 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 <a href="https://linear.app/developers/webhooks" target="_blank" rel="noopener noreferrer">Linear webhooks</a> for supported models). This is not every possible Linear event. Use the <code>type</code> and <code>action</code> 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',
},
},
+9 -4
View File
@@ -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 <code>type</code> and <code>action</code> 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 <code>type</code> and <code>action</code> 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',
},
},
+87 -18
View File
@@ -28,9 +28,18 @@ export function notionSetupInstructions(eventType: string): string {
'Paste the <strong>Webhook URL</strong> above into the URL field.',
`Select the <strong>${eventType}</strong> event type(s).`,
'Notion will send a verification request. Copy the <strong>verification_token</strong> from the payload and paste it into the Notion UI to complete verification.',
'Paste the same <strong>verification_token</strong> into the <strong>Webhook Secret</strong> 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 <strong>comment read</strong> 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<string, TriggerOutput> {
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<string, TriggerOutput> {
*/
function buildEntityOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput> {
...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<string, TriggerOutput> {
...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<string, TriggerOutput> {
...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<string, TriggerOutput> {
...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<string, string[]> = {
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'],
}
+72 -1
View File
@@ -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<string, string> = {
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<string, TriggerOutput> = {
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<string, TriggerOutput> {
return {
...commonEmailOutputs,
...recipientOutputs,
...resendEventDataOutput,
} as Record<string, TriggerOutput>
}
@@ -109,6 +173,7 @@ export function buildEmailDeliveredOutputs(): Record<string, TriggerOutput> {
return {
...commonEmailOutputs,
...recipientOutputs,
...resendEventDataOutput,
} as Record<string, TriggerOutput>
}
@@ -119,6 +184,7 @@ export function buildEmailBouncedOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput> {
return {
...commonEmailOutputs,
...recipientOutputs,
...resendEventDataOutput,
} as Record<string, TriggerOutput>
}
@@ -142,6 +209,7 @@ export function buildEmailOpenedOutputs(): Record<string, TriggerOutput> {
return {
...commonEmailOutputs,
...recipientOutputs,
...resendEventDataOutput,
} as Record<string, TriggerOutput>
}
@@ -152,6 +220,7 @@ export function buildEmailClickedOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput> {
return {
...commonEmailOutputs,
...recipientOutputs,
...resendEventDataOutput,
} as Record<string, TriggerOutput>
}
@@ -177,6 +247,7 @@ export function buildResendOutputs(): Record<string, TriggerOutput> {
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' },
+2 -1
View File
@@ -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 <code>data</code> for the full payload.',
version: '1.0.0',
icon: ResendIcon,
@@ -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(),
@@ -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(),
+216 -25
View File
@@ -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<string, unknown>,
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 <strong>Webhook URL</strong> above.',
'Copy the <strong>Webhook URL</strong> above and generate a <strong>Webhook Secret</strong> (any strong random string). Paste the secret in the <strong>Webhook Secret</strong> field here.',
'In your Flow’s HTTP Callout, set header <code>Authorization: Bearer &lt;your secret&gt;</code> or <code>X-Sim-Webhook-Secret: &lt;your secret&gt;</code> (same value).',
'In Salesforce, go to <strong>Setup → Flows</strong> and click <strong>New Flow</strong>.',
'Select <strong>Record-Triggered Flow</strong> and choose the object(s) you want to monitor.',
'Add an <strong>HTTP Callout</strong> action — set the method to <strong>POST</strong> and paste the webhook URL.',
'In the request body, include the record fields you want sent as <strong>JSON</strong> (e.g., Id, Name, and any relevant fields).',
'Repeat for each object type you want to send events for.',
'Add an <strong>Action</strong> that performs an <strong>HTTP Callout</strong> — method <strong>POST</strong>, <code>Content-Type: application/json</code>, and paste the webhook URL.',
'Build the request body as <strong>JSON</strong> (not SOAP/XML). Include <code>eventType</code> and record fields (e.g. <code>Id</code>, <code>Name</code>). Outbound Messages use SOAP and will not work with this trigger.',
'Save and <strong>Activate</strong> the Flow(s).',
'<strong>Save this trigger in Sim first</strong> so the URL is registered; Salesforce connectivity checks may arrive before the Flow runs.',
'Click <strong>"Save"</strong> above to activate your trigger.',
]
: [
'Copy the <strong>Webhook URL</strong> above.',
'Copy the <strong>Webhook URL</strong> above and set a <strong>Webhook Secret</strong>. In the Flow HTTP Callout, send the same value as <code>Authorization: Bearer …</code> or <code>X-Sim-Webhook-Secret: …</code>.',
'In Salesforce, go to <strong>Setup → Flows</strong> and click <strong>New Flow</strong>.',
`Select <strong>Record-Triggered Flow</strong> and choose the object and <strong>${eventType}</strong> trigger condition.`,
'Add an <strong>HTTP Callout</strong> action — set the method to <strong>POST</strong> and paste the webhook URL.',
'In the request body, include the record fields you want sent as <strong>JSON</strong> (e.g., Id, Name, and any relevant fields).',
`Select <strong>Record-Triggered Flow</strong> for the right object and <strong>${eventType}</strong> as the entry condition.`,
'Add an <strong>HTTP Callout</strong> — <strong>POST</strong>, JSON body, URL = webhook URL.',
`Include <code>eventType</code> in the JSON body using a value this trigger accepts (e.g. for Record Created use <code>record_created</code>, <code>created</code>, or <code>after_insert</code>).`,
'If you use <strong>Object Type (Optional)</strong>, you must also include matching type metadata in the JSON body (for example <code>objectType</code>, <code>sobjectType</code>, or <code>attributes.type</code>) or the event will be rejected.',
'Save and <strong>Activate</strong> the Flow.',
'Click <strong>"Save"</strong> above to activate your trigger.',
'<em>Alternative: You can also use <strong>Setup → Outbound Messages</strong> with a Workflow Rule, but this sends SOAP/XML instead of JSON.</em>',
]
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<string, TriggerOutput> {
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<string, TriggerOutput> {
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<string, TriggerOutput> {
export function buildSalesforceOpportunityStageOutputs(): Record<string, TriggerOutput> {
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<string, Trigger
Amount: { type: 'string', description: 'Deal amount' },
CloseDate: { type: 'string', description: 'Expected close date' },
Probability: { type: 'string', description: 'Win probability' },
AccountId: { type: 'string', description: 'Related Account ID (standard Opportunity field)' },
OwnerId: { type: 'string', description: 'Opportunity owner ID' },
},
previousStage: { type: 'string', description: 'Previous stage name' },
newStage: { type: 'string', description: 'New stage name' },
@@ -123,6 +301,11 @@ export function buildSalesforceOpportunityStageOutputs(): Record<string, Trigger
export function buildSalesforceCaseStatusOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput
Status: { type: 'string', description: 'Current case status' },
Priority: { type: 'string', description: 'Case priority' },
CaseNumber: { type: 'string', description: 'Case number' },
AccountId: { type: 'string', description: 'Related Account ID' },
ContactId: { type: 'string', description: 'Related Contact ID' },
OwnerId: { type: 'string', description: 'Case owner ID' },
},
previousStatus: { type: 'string', description: 'Previous case status' },
newStatus: { type: 'string', description: 'New case status' },
@@ -145,6 +331,11 @@ export function buildSalesforceCaseStatusOutputs(): Record<string, TriggerOutput
export function buildSalesforceWebhookOutputs(): Record<string, TriggerOutput> {
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)' },
+2
View File
@@ -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(),
+61
View File
@@ -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<string, unknown>
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 })
})
})
+114 -31
View File
@@ -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<string, string> = {
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<string, TriggerOutput> {
return {
...coreOutputs,
...payloadOutput,
...deploymentOutputs,
} as Record<string, TriggerOutput>
}
@@ -175,7 +270,11 @@ export function buildDeploymentOutputs(): Record<string, TriggerOutput> {
export function buildProjectOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...payloadOutput,
...linksOutputs,
...deploymentResourceOutputs,
...projectOutputs,
...deploymentTargetPlanDomain,
} as Record<string, TriggerOutput>
}
@@ -185,6 +284,10 @@ export function buildProjectOutputs(): Record<string, TriggerOutput> {
export function buildDomainOutputs(): Record<string, TriggerOutput> {
return {
...coreOutputs,
...payloadOutput,
...linksOutputs,
...deploymentResourceOutputs,
...deploymentTargetPlanDomain,
...domainOutputs,
} as Record<string, TriggerOutput>
}
@@ -196,31 +299,11 @@ export function buildVercelOutputs(): Record<string, TriggerOutput> {
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<string, TriggerOutput>
}
+8 -5
View File
@@ -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'),
}),
+94 -55
View File
@@ -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 <strong>Webhook URL</strong> above.',
'Go to the <a href="https://marketplace.zoom.us/" target="_blank" rel="noopener noreferrer">Zoom Marketplace</a> and open your app (or create a new Webhook Only app).',
'Go to the <a href="https://marketplace.zoom.us/" target="_blank" rel="noopener noreferrer">Zoom Marketplace</a> and create or open a <strong>Webhook-only</strong> or general app with <strong>Event Subscriptions</strong> enabled (Meeting / Recording events as needed). Admin approval may be required for account-level webhooks.',
"Copy the <strong>Secret Token</strong> from your Zoom app's <strong>Features</strong> page and paste it in the <strong>Secret Token</strong> field above.",
'Click <strong>"Save Configuration"</strong> above to activate the trigger.',
'Navigate to <strong>Features > Event Subscriptions</strong> and click <strong>Add Event Subscription</strong>.',
@@ -123,23 +128,45 @@ export function buildMeetingOutputs(): Record<string, TriggerOutput> {
},
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<string, TriggerOutput> {
},
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<string, TriggerOutput> {
},
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',
},
},
},