Skip to content

Commit 5ee6625

Browse files
feat(webhook-triggers): multiple webhook trigger blocks (#725)
* checkpoint * correctly clear status * works * improvements * fix build issue * add docs * remove comments, logs * fix migration to have foreign ref key * change filename to snake case * modified dropdown to match combobox styling * added block type for triggers, split out schedule block into a separate trigger * added chat trigger to start block, removed startAt from schedule modal, added chat fields into tag dropdown for start block * removed startedAt for schedules, separated schedules into a separate block, removed unique constraint on scheule workflows and added combo constraint on workflowid/blockid and schedule * icons fix --------- Co-authored-by: Waleed Latif <walif6@gmail.com>
1 parent 7b73dfb commit 5ee6625

File tree

45 files changed

+7030
-629
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

45 files changed

+7030
-629
lines changed

apps/docs/content/docs/blocks/meta.json

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
"agent",
55
"api",
66
"condition",
7-
"function",
87
"evaluator",
9-
"router",
10-
"response",
11-
"workflow",
8+
"function",
129
"loop",
13-
"parallel"
10+
"parallel",
11+
"response",
12+
"router",
13+
"webhook_trigger",
14+
"workflow"
1415
]
1516
}
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
---
2+
title: Webhook Trigger
3+
description: Trigger workflow execution from external webhooks
4+
---
5+
6+
import { Callout } from 'fumadocs-ui/components/callout'
7+
import { Step, Steps } from 'fumadocs-ui/components/steps'
8+
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
9+
import { Card, Cards } from 'fumadocs-ui/components/card'
10+
import { ThemeImage } from '@/components/ui/theme-image'
11+
12+
The Webhook Trigger block allows external services to trigger your workflow execution through HTTP webhooks. Unlike starter blocks, webhook triggers are pure input sources that start workflows without requiring manual intervention.
13+
14+
<ThemeImage
15+
lightSrc="/static/light/webhooktrigger-light.png"
16+
darkSrc="/static/dark/webhooktrigger-dark.png"
17+
alt="Webhook Trigger Block"
18+
width={350}
19+
height={175}
20+
/>
21+
22+
<Callout>
23+
Webhook triggers cannot receive incoming connections and do not expose webhook data to the workflow. They serve as pure execution triggers.
24+
</Callout>
25+
26+
## Overview
27+
28+
The Webhook Trigger block enables you to:
29+
30+
<Steps>
31+
<Step>
32+
<strong>Receive external triggers</strong>: Accept HTTP requests from external services
33+
</Step>
34+
<Step>
35+
<strong>Support multiple providers</strong>: Handle webhooks from Slack, Gmail, GitHub, and more
36+
</Step>
37+
<Step>
38+
<strong>Start workflows automatically</strong>: Execute workflows without manual intervention
39+
</Step>
40+
<Step>
41+
<strong>Provide secure endpoints</strong>: Generate unique webhook URLs for each trigger
42+
</Step>
43+
</Steps>
44+
45+
## How It Works
46+
47+
The Webhook Trigger block operates as a pure input source:
48+
49+
1. **Generate Endpoint** - Creates a unique webhook URL when configured
50+
2. **Receive Request** - Accepts HTTP POST requests from external services
51+
3. **Trigger Execution** - Starts the workflow when a valid request is received
52+
53+
## Configuration Options
54+
55+
### Webhook Provider
56+
57+
Choose from supported service providers:
58+
59+
<Cards>
60+
<Card title="Slack" href="#">
61+
Receive events from Slack apps and bots
62+
</Card>
63+
<Card title="Gmail" href="#">
64+
Handle email-based triggers and notifications
65+
</Card>
66+
<Card title="Airtable" href="#">
67+
Respond to database changes
68+
</Card>
69+
<Card title="Telegram" href="#">
70+
Process bot messages and updates
71+
</Card>
72+
<Card title="WhatsApp" href="#">
73+
Handle messaging events
74+
</Card>
75+
<Card title="GitHub" href="#">
76+
Process repository events and pull requests
77+
</Card>
78+
<Card title="Discord" href="#">
79+
Respond to Discord server events
80+
</Card>
81+
<Card title="Stripe" href="#">
82+
Handle payment and subscription events
83+
</Card>
84+
</Cards>
85+
86+
### Generic Webhooks
87+
88+
For custom integrations or services not listed above, use the **Generic** provider. This option accepts HTTP POST requests from any client and provides flexible authentication options:
89+
90+
- **Optional Authentication** - Configure Bearer token or custom header authentication
91+
- **IP Restrictions** - Limit access to specific IP addresses
92+
- **Request Deduplication** - Automatic duplicate request detection using content hashing
93+
- **Flexible Headers** - Support for custom authentication header names
94+
95+
The Generic provider is ideal for internal services, custom applications, or third-party tools that need to trigger workflows via standard HTTP requests.
96+
97+
### Webhook Configuration
98+
99+
Configure provider-specific settings:
100+
101+
- **Webhook URL** - Automatically generated unique endpoint
102+
- **Provider Settings** - Authentication and validation options
103+
- **Security** - Built-in rate limiting and provider-specific authentication
104+
105+
## Best Practices
106+
107+
- **Use unique webhook URLs** for each integration to maintain security
108+
- **Configure proper authentication** when supported by the provider
109+
- **Keep workflows independent** of webhook payload structure
110+
- **Test webhook endpoints** before deploying to production
111+
- **Monitor webhook delivery** through provider dashboards
112+
113+
34.4 KB
Loading
35.8 KB
Loading

apps/sim/app/api/schedules/[id]/route.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,29 @@ export async function PUT(request: NextRequest, { params }: { params: Promise<{
141141
})
142142
}
143143

144+
if (action === 'disable' || (body.status && body.status === 'disabled')) {
145+
if (schedule.status === 'disabled') {
146+
return NextResponse.json({ message: 'Schedule is already disabled' }, { status: 200 })
147+
}
148+
149+
const now = new Date()
150+
151+
await db
152+
.update(workflowSchedule)
153+
.set({
154+
status: 'disabled',
155+
updatedAt: now,
156+
nextRunAt: null, // Clear next run time when disabled
157+
})
158+
.where(eq(workflowSchedule.id, scheduleId))
159+
160+
logger.info(`[${requestId}] Disabled schedule: ${scheduleId}`)
161+
162+
return NextResponse.json({
163+
message: 'Schedule disabled successfully',
164+
})
165+
}
166+
144167
logger.warn(`[${requestId}] Unsupported update action for schedule: ${scheduleId}`)
145168
return NextResponse.json({ error: 'Unsupported update action' }, { status: 400 })
146169
} catch (error) {

apps/sim/app/api/schedules/execute/route.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -46,10 +46,13 @@ function calculateNextRunTime(
4646
schedule: typeof workflowSchedule.$inferSelect,
4747
blocks: Record<string, BlockState>
4848
): Date {
49-
const starterBlock = Object.values(blocks).find((block) => block.type === 'starter')
50-
if (!starterBlock) throw new Error('No starter block found')
51-
const scheduleType = getSubBlockValue(starterBlock, 'scheduleType')
52-
const scheduleValues = getScheduleTimeValues(starterBlock)
49+
// Look for either starter block or schedule trigger block
50+
const scheduleBlock = Object.values(blocks).find(
51+
(block) => block.type === 'starter' || block.type === 'schedule'
52+
)
53+
if (!scheduleBlock) throw new Error('No starter or schedule block found')
54+
const scheduleType = getSubBlockValue(scheduleBlock, 'scheduleType')
55+
const scheduleValues = getScheduleTimeValues(scheduleBlock)
5356

5457
if (schedule.cronExpression) {
5558
const cron = new Cron(schedule.cronExpression)
@@ -401,7 +404,10 @@ export async function GET() {
401404
// Set up enhanced logging on the executor
402405
loggingSession.setupExecutor(executor)
403406

404-
const result = await executor.execute(schedule.workflowId)
407+
const result = await executor.execute(
408+
schedule.workflowId,
409+
schedule.blockId || undefined
410+
)
405411

406412
const executionResult =
407413
'stream' in result && 'execution' in result ? result.execution : result

apps/sim/app/api/schedules/route.ts

Lines changed: 75 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import crypto from 'crypto'
2-
import { eq } from 'drizzle-orm'
2+
import { and, eq } from 'drizzle-orm'
33
import { type NextRequest, NextResponse } from 'next/server'
44
import { z } from 'zod'
55
import { getSession } from '@/lib/auth'
@@ -19,6 +19,7 @@ const logger = createLogger('ScheduledAPI')
1919

2020
const ScheduleRequestSchema = z.object({
2121
workflowId: z.string(),
22+
blockId: z.string().optional(),
2223
state: z.object({
2324
blocks: z.record(z.any()),
2425
edges: z.array(z.any()),
@@ -66,6 +67,7 @@ export async function GET(req: NextRequest) {
6667
const requestId = crypto.randomUUID().slice(0, 8)
6768
const url = new URL(req.url)
6869
const workflowId = url.searchParams.get('workflowId')
70+
const blockId = url.searchParams.get('blockId')
6971
const mode = url.searchParams.get('mode')
7072

7173
if (mode && mode !== 'schedule') {
@@ -92,10 +94,16 @@ export async function GET(req: NextRequest) {
9294
recentRequests.set(workflowId, now)
9395
}
9496

97+
// Build query conditions
98+
const conditions = [eq(workflowSchedule.workflowId, workflowId)]
99+
if (blockId) {
100+
conditions.push(eq(workflowSchedule.blockId, blockId))
101+
}
102+
95103
const schedule = await db
96104
.select()
97105
.from(workflowSchedule)
98-
.where(eq(workflowSchedule.workflowId, workflowId))
106+
.where(conditions.length > 1 ? and(...conditions) : conditions[0])
99107
.limit(1)
100108

101109
const headers = new Headers()
@@ -138,36 +146,81 @@ export async function POST(req: NextRequest) {
138146
}
139147

140148
const body = await req.json()
141-
const { workflowId, state } = ScheduleRequestSchema.parse(body)
149+
const { workflowId, blockId, state } = ScheduleRequestSchema.parse(body)
142150

143151
logger.info(`[${requestId}] Processing schedule update for workflow ${workflowId}`)
144152

145-
const starterBlock = Object.values(state.blocks).find(
146-
(block: any) => block.type === 'starter'
147-
) as BlockState | undefined
153+
// Find the target block - prioritize the specific blockId if provided
154+
let targetBlock: BlockState | undefined
155+
if (blockId) {
156+
// If blockId is provided, find that specific block
157+
targetBlock = Object.values(state.blocks).find((block: any) => block.id === blockId) as
158+
| BlockState
159+
| undefined
160+
} else {
161+
// Fallback: find either starter block or schedule trigger block
162+
targetBlock = Object.values(state.blocks).find(
163+
(block: any) => block.type === 'starter' || block.type === 'schedule'
164+
) as BlockState | undefined
165+
}
148166

149-
if (!starterBlock) {
150-
logger.warn(`[${requestId}] No starter block found in workflow ${workflowId}`)
151-
return NextResponse.json({ error: 'No starter block found in workflow' }, { status: 400 })
167+
if (!targetBlock) {
168+
logger.warn(`[${requestId}] No starter or schedule block found in workflow ${workflowId}`)
169+
return NextResponse.json(
170+
{ error: 'No starter or schedule block found in workflow' },
171+
{ status: 400 }
172+
)
152173
}
153174

154-
const startWorkflow = getSubBlockValue(starterBlock, 'startWorkflow')
155-
const scheduleType = getSubBlockValue(starterBlock, 'scheduleType')
175+
const startWorkflow = getSubBlockValue(targetBlock, 'startWorkflow')
176+
const scheduleType = getSubBlockValue(targetBlock, 'scheduleType')
177+
178+
const scheduleValues = getScheduleTimeValues(targetBlock)
156179

157-
const scheduleValues = getScheduleTimeValues(starterBlock)
180+
const hasScheduleConfig = hasValidScheduleConfig(scheduleType, scheduleValues, targetBlock)
158181

159-
const hasScheduleConfig = hasValidScheduleConfig(scheduleType, scheduleValues, starterBlock)
182+
// For schedule trigger blocks, we always have valid configuration
183+
// For starter blocks, check if schedule is selected and has valid config
184+
const isScheduleBlock = targetBlock.type === 'schedule'
185+
const hasValidConfig = isScheduleBlock || (startWorkflow === 'schedule' && hasScheduleConfig)
160186

161-
if (startWorkflow !== 'schedule' && !hasScheduleConfig) {
187+
// Debug logging to understand why validation fails
188+
logger.info(`[${requestId}] Schedule validation debug:`, {
189+
workflowId,
190+
blockId,
191+
blockType: targetBlock.type,
192+
isScheduleBlock,
193+
startWorkflow,
194+
scheduleType,
195+
hasScheduleConfig,
196+
hasValidConfig,
197+
scheduleValues: {
198+
minutesInterval: scheduleValues.minutesInterval,
199+
dailyTime: scheduleValues.dailyTime,
200+
cronExpression: scheduleValues.cronExpression,
201+
},
202+
})
203+
204+
if (!hasValidConfig) {
162205
logger.info(
163206
`[${requestId}] Removing schedule for workflow ${workflowId} - no valid configuration found`
164207
)
165-
await db.delete(workflowSchedule).where(eq(workflowSchedule.workflowId, workflowId))
208+
// Build delete conditions
209+
const deleteConditions = [eq(workflowSchedule.workflowId, workflowId)]
210+
if (blockId) {
211+
deleteConditions.push(eq(workflowSchedule.blockId, blockId))
212+
}
213+
214+
await db
215+
.delete(workflowSchedule)
216+
.where(deleteConditions.length > 1 ? and(...deleteConditions) : deleteConditions[0])
166217

167218
return NextResponse.json({ message: 'Schedule removed' })
168219
}
169220

170-
if (startWorkflow !== 'schedule') {
221+
if (isScheduleBlock) {
222+
logger.info(`[${requestId}] Processing schedule trigger block for workflow ${workflowId}`)
223+
} else if (startWorkflow !== 'schedule') {
171224
logger.info(
172225
`[${requestId}] Setting workflow to scheduled mode based on schedule configuration`
173226
)
@@ -177,12 +230,12 @@ export async function POST(req: NextRequest) {
177230

178231
let cronExpression: string | null = null
179232
let nextRunAt: Date | undefined
180-
const timezone = getSubBlockValue(starterBlock, 'timezone') || 'UTC'
233+
const timezone = getSubBlockValue(targetBlock, 'timezone') || 'UTC'
181234

182235
try {
183236
const defaultScheduleType = scheduleType || 'daily'
184-
const scheduleStartAt = getSubBlockValue(starterBlock, 'scheduleStartAt')
185-
const scheduleTime = getSubBlockValue(starterBlock, 'scheduleTime')
237+
const scheduleStartAt = getSubBlockValue(targetBlock, 'scheduleStartAt')
238+
const scheduleTime = getSubBlockValue(targetBlock, 'scheduleTime')
186239

187240
logger.debug(`[${requestId}] Schedule configuration:`, {
188241
type: defaultScheduleType,
@@ -218,6 +271,7 @@ export async function POST(req: NextRequest) {
218271
const values = {
219272
id: crypto.randomUUID(),
220273
workflowId,
274+
blockId,
221275
cronExpression,
222276
triggerType: 'schedule',
223277
createdAt: new Date(),
@@ -229,6 +283,7 @@ export async function POST(req: NextRequest) {
229283
}
230284

231285
const setValues = {
286+
blockId,
232287
cronExpression,
233288
updatedAt: new Date(),
234289
nextRunAt,
@@ -241,7 +296,7 @@ export async function POST(req: NextRequest) {
241296
.insert(workflowSchedule)
242297
.values(values)
243298
.onConflictDoUpdate({
244-
target: [workflowSchedule.workflowId],
299+
target: [workflowSchedule.workflowId, workflowSchedule.blockId],
245300
set: setValues,
246301
})
247302

0 commit comments

Comments
 (0)