Skip to content

Commit bbb7161

Browse files
authored
feat/389-Webhooks-Headers-Query-params-Methods-validation (#6217)
* feat(ambient-agents): Add webhook trigger UI on start node, handles in both canvas, agentflow is out of scope but shows temporary ui * fix: resolve webhookURL copy button not appearing after first save useParams() does not update when window.history.replaceState() is used on first save (bypasses React Router). Fall back to Redux canvas.chatflow.id so NodeInputHandler re-renders reactively when SET_CHATFLOW is dispatched. * 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 * fixed a bug where downstream nodes cant reference values via node id, and a lowercase headers issue
1 parent 22472f5 commit bbb7161

8 files changed

Lines changed: 364 additions & 23 deletions

File tree

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

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,16 +132,71 @@ class Start_Agentflow implements INode {
132132
}
133133
]
134134
},
135+
{
136+
label: 'HTTP Method',
137+
name: 'webhookMethod',
138+
type: 'options',
139+
options: [
140+
{ label: 'GET', name: 'GET' },
141+
{ label: 'POST', name: 'POST' },
142+
{ label: 'PUT', name: 'PUT' },
143+
{ label: 'PATCH', name: 'PATCH' },
144+
{ label: 'DELETE', name: 'DELETE' }
145+
],
146+
default: 'POST',
147+
show: {
148+
startInputType: 'webhookTrigger'
149+
}
150+
},
151+
{
152+
label: 'Content Type',
153+
name: 'webhookContentType',
154+
type: 'options',
155+
description:
156+
'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.',
157+
options: [
158+
{ label: 'application/json', name: 'application/json' },
159+
{ label: 'application/x-www-form-urlencoded', name: 'application/x-www-form-urlencoded' }
160+
],
161+
default: 'application/json',
162+
optional: true,
163+
show: {
164+
startInputType: 'webhookTrigger'
165+
}
166+
},
135167
{
136168
label: 'Webhook URL',
137169
name: 'webhookURL',
138170
type: 'string',
139-
description: 'Send a POST request to this URL to trigger the workflow',
171+
description: 'Send a request to this URL to trigger the workflow',
140172
optional: true,
141173
show: {
142174
startInputType: 'webhookTrigger'
143175
}
144176
},
177+
{
178+
label: 'Expected Query Parameters',
179+
name: 'webhookQueryParams',
180+
description: 'Declare expected query parameters. Leave empty to accept any.',
181+
type: 'array',
182+
optional: true,
183+
show: {
184+
startInputType: 'webhookTrigger'
185+
},
186+
array: [
187+
{
188+
label: 'Variable Name',
189+
name: 'name',
190+
type: 'string',
191+
placeholder: 'e.g. page'
192+
},
193+
{
194+
label: 'Required',
195+
name: 'required',
196+
type: 'boolean'
197+
}
198+
]
199+
},
145200
{
146201
label: 'Expected Body Parameters',
147202
name: 'webhookBodyParams',
@@ -185,6 +240,29 @@ class Start_Agentflow implements INode {
185240
}
186241
]
187242
},
243+
{
244+
label: 'Expected Headers',
245+
name: 'webhookHeaderParams',
246+
description: 'Declare expected request headers. Leave empty to accept any.',
247+
type: 'array',
248+
optional: true,
249+
show: {
250+
startInputType: 'webhookTrigger'
251+
},
252+
array: [
253+
{
254+
label: 'Header Name',
255+
name: 'name',
256+
type: 'string',
257+
placeholder: 'e.g. x-github-event'
258+
},
259+
{
260+
label: 'Required',
261+
name: 'required',
262+
type: 'boolean'
263+
}
264+
]
265+
},
188266
{
189267
label: 'Ephemeral Memory',
190268
name: 'startEphemeralMemory',
@@ -275,7 +353,12 @@ class Start_Agentflow implements INode {
275353

276354
if (startInputType === 'webhookTrigger') {
277355
inputData.webhook = input
278-
let webhookOutput = input
356+
let webhookOutput: string | Record<string, any> = input
357+
try {
358+
webhookOutput = typeof input === 'string' ? JSON.parse(input) : input
359+
} catch (_) {
360+
/* keep as-is */
361+
}
279362
if (options.agentflowRuntime?.webhook && Object.keys(options.agentflowRuntime.webhook).length) {
280363
webhookOutput = options.agentflowRuntime.webhook
281364
}

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)