Skip to content

Commit 58f4676

Browse files
committed
feat(webhooks): add headers, query params & body validation to webhook trigger
- Add webhookTrigger input type to Start node with HTTP method, content type, and expected headers/query/body param configuration - New /api/v1/webhook/:id route with method, content-type, header, body, and query param validation (400/405/415 on mismatch) - Namespace webhook payload as $webhook.body.*, $webhook.headers.*, $webhook.query.* in the flow runtime - Resolve $webhook.* variables in downstream nodes via buildAgentflow.ts - Auto-unwrap form-encoded `payload` JSON strings (e.g. GitHub webhooks) so $webhook.body.* paths work regardless of content type - Expose webhook variable suggestions in the node variable picker - Show copyable webhook URL in the Start node canvas UI
1 parent bd1e913 commit 58f4676

8 files changed

Lines changed: 357 additions & 22 deletions

File tree

packages/components/nodes/agentflow/Start/Start.ts

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -131,16 +131,71 @@ class Start_Agentflow implements INode {
131131
}
132132
]
133133
},
134+
{
135+
label: 'HTTP Method',
136+
name: 'webhookMethod',
137+
type: 'options',
138+
options: [
139+
{ label: 'GET', name: 'GET' },
140+
{ label: 'POST', name: 'POST' },
141+
{ label: 'PUT', name: 'PUT' },
142+
{ label: 'PATCH', name: 'PATCH' },
143+
{ label: 'DELETE', name: 'DELETE' }
144+
],
145+
default: 'POST',
146+
show: {
147+
startInputType: 'webhookTrigger'
148+
}
149+
},
150+
{
151+
label: 'Content Type',
152+
name: 'webhookContentType',
153+
type: 'options',
154+
description:
155+
'Expected Content-Type of incoming requests. For application/x-www-form-urlencoded, if the entire payload is a JSON string in a "payload" field (e.g. GitHub webhooks), it is automatically parsed — use $webhook.body.* as normal.',
156+
options: [
157+
{ label: 'application/json', name: 'application/json' },
158+
{ label: 'application/x-www-form-urlencoded', name: 'application/x-www-form-urlencoded' }
159+
],
160+
default: 'application/json',
161+
optional: true,
162+
show: {
163+
startInputType: 'webhookTrigger'
164+
}
165+
},
134166
{
135167
label: 'Webhook URL',
136168
name: 'webhookURL',
137169
type: 'string',
138-
description: 'Send a POST request to this URL to trigger the workflow',
170+
description: 'Send a request to this URL to trigger the workflow',
139171
optional: true,
140172
show: {
141173
startInputType: 'webhookTrigger'
142174
}
143175
},
176+
{
177+
label: 'Expected Query Parameters',
178+
name: 'webhookQueryParams',
179+
description: 'Declare expected query parameters. Leave empty to accept any.',
180+
type: 'array',
181+
optional: true,
182+
show: {
183+
startInputType: 'webhookTrigger'
184+
},
185+
array: [
186+
{
187+
label: 'Variable Name',
188+
name: 'name',
189+
type: 'string',
190+
placeholder: 'e.g. page'
191+
},
192+
{
193+
label: 'Required',
194+
name: 'required',
195+
type: 'boolean'
196+
}
197+
]
198+
},
144199
{
145200
label: 'Expected Body Parameters',
146201
name: 'webhookBodyParams',
@@ -184,6 +239,29 @@ class Start_Agentflow implements INode {
184239
}
185240
]
186241
},
242+
{
243+
label: 'Expected Headers',
244+
name: 'webhookHeaderParams',
245+
description: 'Declare expected request headers. Leave empty to accept any.',
246+
type: 'array',
247+
optional: true,
248+
show: {
249+
startInputType: 'webhookTrigger'
250+
},
251+
array: [
252+
{
253+
label: 'Header Name',
254+
name: 'name',
255+
type: 'string',
256+
placeholder: 'e.g. x-github-event'
257+
},
258+
{
259+
label: 'Required',
260+
name: 'required',
261+
type: 'boolean'
262+
}
263+
]
264+
},
187265
{
188266
label: 'Ephemeral Memory',
189267
name: 'startEphemeralMemory',

packages/server/src/controllers/webhook/index.test.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ const mockReq = (overrides: Partial<Request> = {}): Request =>
2626
({
2727
params: { id: 'chatflow-123' },
2828
body: { foo: 'bar' },
29+
method: 'POST',
30+
headers: { 'content-type': 'application/json' },
31+
query: { page: '1' },
2932
user: undefined,
3033
...overrides
3134
} as unknown as Request)
@@ -77,7 +80,44 @@ describe('createWebhook', () => {
7780

7881
await webhookController.createWebhook(req, res, next)
7982

80-
expect(mockBuildChatflow).toHaveBeenCalledWith(expect.objectContaining({ body: { webhook: { body: originalBody } } }))
83+
expect(mockBuildChatflow).toHaveBeenCalledWith(
84+
expect.objectContaining({
85+
body: {
86+
webhook: {
87+
body: originalBody,
88+
headers: expect.any(Object),
89+
query: expect.any(Object)
90+
}
91+
}
92+
})
93+
)
94+
})
95+
96+
it('builds namespaced webhook payload with body, headers, and query', async () => {
97+
mockValidateWebhookChatflow.mockResolvedValue(undefined)
98+
mockBuildChatflow.mockResolvedValue({})
99+
100+
const req = mockReq({
101+
body: { action: 'push' },
102+
headers: { 'x-github-event': 'push' } as any,
103+
query: { page: '2' } as any
104+
})
105+
const res = mockRes()
106+
const next = mockNext()
107+
108+
await webhookController.createWebhook(req, res, next)
109+
110+
expect(mockBuildChatflow).toHaveBeenCalledWith(
111+
expect.objectContaining({
112+
body: {
113+
webhook: {
114+
body: { action: 'push' },
115+
headers: expect.objectContaining({ 'x-github-event': 'push' }),
116+
query: { page: '2' }
117+
}
118+
}
119+
})
120+
)
81121
})
82122

83123
it('returns buildChatflow result as JSON response', async () => {
@@ -119,6 +159,13 @@ describe('createWebhook', () => {
119159

120160
await webhookController.createWebhook(req, res, next)
121161

122-
expect(mockValidateWebhookChatflow).toHaveBeenCalledWith('chatflow-123', undefined, { foo: 'bar' })
162+
expect(mockValidateWebhookChatflow).toHaveBeenCalledWith(
163+
'chatflow-123',
164+
undefined,
165+
{ foo: 'bar' },
166+
'POST',
167+
expect.any(Object),
168+
expect.any(Object)
169+
)
123170
})
124171
})

packages/server/src/controllers/webhook/index.ts

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,28 @@ const createWebhook = async (req: Request, res: Response, next: NextFunction) =>
1313

1414
const workspaceId = req.user?.activeWorkspaceId
1515

16-
await webhookService.validateWebhookChatflow(req.params.id, workspaceId, req.body)
16+
// For form-encoded requests, unwrap JSON encoded in a `payload` field (e.g. GitHub webhooks)
17+
// so $webhook.body.* resolves against the actual payload fields.
18+
const contentType = (req.headers['content-type'] ?? '').toLowerCase()
19+
let body = req.body
20+
if (contentType.startsWith('application/x-www-form-urlencoded') && typeof body?.payload === 'string') {
21+
try {
22+
body = JSON.parse(body.payload)
23+
} catch {
24+
// leave body as-is if payload isn't valid JSON
25+
}
26+
}
27+
28+
await webhookService.validateWebhookChatflow(req.params.id, workspaceId, body, req.method, req.headers, req.query)
1729

1830
// Namespace the webhook payload so $webhook.body.*, $webhook.headers.*, $webhook.query.* can coexist
19-
req.body = { webhook: { body: req.body } }
31+
req.body = {
32+
webhook: {
33+
body,
34+
headers: req.headers,
35+
query: req.query
36+
}
37+
}
2038

2139
const apiResponse = await predictionsServices.buildChatflow(req)
2240
return res.json(apiResponse)

packages/server/src/routes/webhook/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import webhookController from '../../controllers/webhook'
44
const router = express.Router()
55

66
// Unauthenticated at route level — API key validation happens downstream in utilBuildChatflow.
7-
// Method filtering (GET/POST/etc.) TODO: will be enforced in the service once the Start node config is read.
87
router.all('/:id', webhookController.getRateLimiterMiddleware, webhookController.createWebhook)
98

109
export default router

packages/server/src/services/webhook/index.test.ts

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,16 @@ jest.mock('../chatflows', () => ({
1010

1111
import webhookService from './index'
1212

13-
const makeChatflow = (startInputType: string, webhookBodyParams?: unknown) => ({
13+
const makeChatflow = (
14+
startInputType: string,
15+
inputs?: {
16+
webhookBodyParams?: unknown
17+
webhookMethod?: string
18+
webhookContentType?: string
19+
webhookHeaderParams?: unknown
20+
webhookQueryParams?: unknown
21+
}
22+
) => ({
1423
id: 'test-id',
1524
flowData: JSON.stringify({
1625
nodes: [
@@ -20,7 +29,7 @@ const makeChatflow = (startInputType: string, webhookBodyParams?: unknown) => ({
2029
name: 'startAgentflow',
2130
inputs: {
2231
startInputType,
23-
...(webhookBodyParams !== undefined && { webhookBodyParams })
32+
...inputs
2433
}
2534
}
2635
}
@@ -71,16 +80,83 @@ describe('validateWebhookChatflow', () => {
7180
await expect(webhookService.validateWebhookChatflow('some-id')).rejects.toBe(original)
7281
})
7382

83+
// --- Method validation ---
84+
85+
it('throws 405 when HTTP method does not match configured webhookMethod', async () => {
86+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookMethod: 'POST' }))
87+
88+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'GET')).rejects.toMatchObject({
89+
statusCode: StatusCodes.METHOD_NOT_ALLOWED
90+
})
91+
})
92+
93+
it('resolves for any method when webhookMethod is not configured', async () => {
94+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger'))
95+
96+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'DELETE')).resolves.toBeUndefined()
97+
})
98+
99+
// --- Content-Type validation ---
100+
101+
it('throws 415 when Content-Type does not match configured webhookContentType', async () => {
102+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' }))
103+
104+
await expect(
105+
webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'text/plain' })
106+
).rejects.toMatchObject({ statusCode: StatusCodes.UNSUPPORTED_MEDIA_TYPE })
107+
})
108+
109+
it('resolves when Content-Type starts with configured value (handles charset suffix)', async () => {
110+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' }))
111+
112+
await expect(
113+
webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'application/json; charset=utf-8' })
114+
).resolves.toBeUndefined()
115+
})
116+
117+
it('resolves when webhookContentType is not configured', async () => {
118+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger'))
119+
120+
await expect(
121+
webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'content-type': 'text/plain' })
122+
).resolves.toBeUndefined()
123+
})
124+
125+
// --- Header validation ---
126+
127+
it('throws 400 when a required header is missing', async () => {
128+
mockGetChatflowById.mockResolvedValue(
129+
makeChatflow('webhookTrigger', { webhookHeaderParams: [{ name: 'x-api-key', required: true }] })
130+
)
131+
132+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {})).rejects.toMatchObject({
133+
statusCode: StatusCodes.BAD_REQUEST,
134+
message: expect.stringContaining('x-api-key')
135+
})
136+
})
137+
138+
it('resolves when all required headers are present', async () => {
139+
mockGetChatflowById.mockResolvedValue(
140+
makeChatflow('webhookTrigger', { webhookHeaderParams: [{ name: 'x-api-key', required: true }] })
141+
)
142+
143+
await expect(
144+
webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'x-api-key': 'secret' })
145+
).resolves.toBeUndefined()
146+
})
147+
148+
// --- Body param validation ---
149+
74150
it('throws 400 when a required param is missing from body', async () => {
75-
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', [{ name: 'action', required: true }]))
151+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] }))
76152

77153
await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({
78154
statusCode: StatusCodes.BAD_REQUEST
79155
})
80156
})
81157

82158
it('includes the missing field name in the 400 error message', async () => {
83-
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', [{ name: 'action', required: true }]))
159+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] }))
84160

85161
await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({
86162
statusCode: StatusCodes.BAD_REQUEST,
@@ -89,13 +165,13 @@ describe('validateWebhookChatflow', () => {
89165
})
90166

91167
it('resolves when all required params are present in body', async () => {
92-
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', [{ name: 'action', required: true }]))
168+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'action', required: true }] }))
93169

94170
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { action: 'push' })).resolves.toBeUndefined()
95171
})
96172

97173
it('resolves when webhookBodyParams is empty string (DB default)', async () => {
98-
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', ''))
174+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookBodyParams: '' }))
99175

100176
await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).resolves.toBeUndefined()
101177
})
@@ -105,4 +181,42 @@ describe('validateWebhookChatflow', () => {
105181

106182
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { anything: 'goes' })).resolves.toBeUndefined()
107183
})
184+
185+
// --- Body type validation ---
186+
187+
it('throws 400 when a declared body param has wrong type', async () => {
188+
mockGetChatflowById.mockResolvedValue(
189+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] })
190+
)
191+
192+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 'not-a-number' })).rejects.toMatchObject({
193+
statusCode: StatusCodes.BAD_REQUEST,
194+
message: expect.stringContaining('count')
195+
})
196+
})
197+
198+
it('resolves when declared body param has correct type', async () => {
199+
mockGetChatflowById.mockResolvedValue(
200+
makeChatflow('webhookTrigger', { webhookBodyParams: [{ name: 'count', type: 'number', required: false }] })
201+
)
202+
203+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 42 })).resolves.toBeUndefined()
204+
})
205+
206+
// --- Query param validation ---
207+
208+
it('throws 400 when a required query param is missing', async () => {
209+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookQueryParams: [{ name: 'page', required: true }] }))
210+
211+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {})).rejects.toMatchObject({
212+
statusCode: StatusCodes.BAD_REQUEST,
213+
message: expect.stringContaining('page')
214+
})
215+
})
216+
217+
it('resolves when all required query params are present', async () => {
218+
mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookQueryParams: [{ name: 'page', required: true }] }))
219+
220+
await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, { page: '2' })).resolves.toBeUndefined()
221+
})
108222
})

0 commit comments

Comments
 (0)