| 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. |
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.
- Research what webhook events the service supports
- Create the trigger files using the generic builder
- Create a provider handler if custom auth, formatting, or subscriptions are needed
- Register triggers and connect them to the block
If the service docs do not clearly show the webhook payload JSON for an event, you MUST tell the user instead of guessing trigger outputs or formatInput mappings.
- Do NOT invent payload field names
- Do NOT guess nested event object paths
- Do NOT infer output fields from the UI or marketing docs
- Do NOT write
formatInputagainst unverified webhook bodies
If the payload shape is unknown, do one of these instead:
- Ask the user for sample webhook payloads
- Ask the user for a test webhook source so you can inspect a real event
- Implement only the event registration/setup portions whose payloads are documented
- Leave the trigger unimplemented and explicitly say which payload fields are unknown
apps/sim/triggers/{service}/
├── index.ts # Barrel exports
├── utils.ts # Service-specific helpers (options, instructions, extra fields, outputs)
├── {event_a}.ts # Primary trigger (includes dropdown)
├── {event_b}.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
This file contains all service-specific helpers used by triggers.
import type { SubBlockConfig } from '@/blocks/types'
import type { TriggerOutput } from '@/triggers/types'
export const {service}TriggerOptions = [
{ label: 'Event A', id: '{service}_event_a' },
{ label: 'Event B', id: '{service}_event_b' },
]
export function {service}SetupInstructions(eventType: string): string {
const instructions = [
'Copy the <strong>Webhook URL</strong> above',
'Go to <strong>{Service} Settings > Webhooks</strong>',
`Select the <strong>${eventType}</strong> event type`,
'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>`
)
.join('')
}
export function build{Service}ExtraFields(triggerId: string): SubBlockConfig[] {
return [
{
id: 'projectId',
title: 'Project ID (Optional)',
type: 'short-input',
placeholder: 'Leave empty for all projects',
mode: 'trigger',
condition: { field: 'selectedTriggerId', value: triggerId },
},
]
}
export function build{Service}Outputs(): Record<string, TriggerOutput> {
return {
eventType: { type: 'string', description: 'The type of event' },
resourceId: { type: 'string', description: 'ID of the affected resource' },
resource: {
id: { type: 'string', description: 'Resource ID' },
name: { type: 'string', description: 'Resource name' },
},
}
}Primary trigger — MUST include includeDropdown: true:
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'
export const {service}EventATrigger: TriggerConfig = {
id: '{service}_event_a',
name: '{Service} Event A',
provider: '{service}',
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,
setupInstructions: {service}SetupInstructions('Event A'),
extraFields: build{Service}ExtraFields('{service}_event_a'),
}),
outputs: build{Service}Outputs(),
webhook: { method: 'POST', headers: { 'Content-Type': 'application/json' } },
}Secondary triggers — NO includeDropdown (it's already in the primary):
export const {service}EventBTrigger: TriggerConfig = {
// Same as above but: id: '{service}_event_b', no includeDropdown
}export { {service}EventATrigger } from './event_a'
export { {service}EventBTrigger } from './event_b'import { {service}EventATrigger, {service}EventBTrigger } from '@/triggers/{service}'
export const TRIGGER_REGISTRY: TriggerRegistry = {
// ... existing ...
{service}_event_a: {service}EventATrigger,
{service}_event_b: {service}EventBTrigger,
}import { getTrigger } from '@/triggers'
export const {Service}Block: BlockConfig = {
// ...
triggers: {
enabled: true,
available: ['{service}_event_a', '{service}_event_b'],
},
subBlocks: [
// Regular tool subBlocks first...
...getTrigger('{service}_event_a').subBlocks,
...getTrigger('{service}_event_b').subBlocks,
],
}All provider-specific webhook logic lives in a single handler file: apps/sim/lib/webhooks/providers/{service}.ts.
| 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 |
If none apply, you don't need a handler. The default handler provides bearer token auth.
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)
}
export const {service}Handler: WebhookProviderHandler = {
verifyAuth: createHmacVerifier({
configKey: 'webhookSecret',
headerName: 'X-{Service}-Signature',
validateFn: validate{Service}Signature,
providerLabel: '{Service}',
}),
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
},
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,
},
}
},
extractIdempotencyId(body: unknown) {
const obj = body as Record<string, unknown>
return obj.id && obj.type ? `${obj.type}:${obj.id}` : null
},
}In apps/sim/lib/webhooks/providers/registry.ts:
import { {service}Handler } from '@/lib/webhooks/providers/{service}'
const PROVIDER_HANDLERS: Record<string, WebhookProviderHandler> = {
// ... existing (alphabetical) ...
{service}: {service}Handler,
}There are two sources of truth that MUST be aligned:
- Trigger
outputs— schema defining what fields SHOULD be available (UI tag dropdown) formatInputon 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 triggeroutputsexactly - Return
{ input: ..., skip: { message: '...' } }to skip execution - No wrapper objects or duplication
- Use
nullfor missing optional data
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.
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 intoproviderConfig - Add
apiKeyfield tobuild{Service}ExtraFieldswithpassword: true
Trigger outputs use the same schema as block outputs (NOT tool outputs).
Supported: type + description for leaf fields, nested objects for complex data.
NOT supported: optional: true, items (those are tool-output-only features).
export function buildOutputs(): Record<string, TriggerOutput> {
return {
eventType: { type: 'string', description: 'Event type' },
timestamp: { type: 'string', description: 'When it occurred' },
payload: { type: 'json', description: 'Full event payload' },
resource: {
id: { type: 'string', description: 'Resource ID' },
name: { type: 'string', description: 'Resource name' },
},
}
}- Created
utils.tswith options, instructions, extra fields, and output builders - Primary trigger has
includeDropdown: true; secondary triggers do NOT - All triggers use
buildTriggerSubBlockshelper - Created
index.tsbarrel export
- All triggers in
triggers/registry.ts→TRIGGER_REGISTRY - Block has
triggers.enabled: trueand lists all trigger IDs intriggers.available - Block spreads all trigger subBlocks:
...getTrigger('id').subBlocks
- 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
-
formatInputoutput keys match triggeroutputsexactly - Event matching uses dynamic
await import()for trigger utils
-
createSubscriptionanddeleteSubscriptionon the handler - NO changes to
route.ts,provider-subscriptions.ts, ordeploy.ts - API key field uses
password: true
-
bun run type-checkpasses - Manually verify
formatInputoutput keys match triggeroutputskeys - Trigger UI shows correctly in the block