Skip to content

Commit bf2e368

Browse files
authored
feat:367 | Webhooks - Human Input + Callback Support (#6263)
* Added HITL support for webhooks * feat: add async callback URL to webhook trigger Optional Callback URL / Secret on the Start node (or x-callback-url header). Webhook now returns 202 immediately and POSTs SUCCESS, STOPPED (HITL), or ERROR to the callback URL, signed with HMAC-SHA256 when a secret is set. Retries 3x with 0s/3s/6s backoff. * used getErrorMessage for error messages
1 parent f8cf2ee commit bf2e368

8 files changed

Lines changed: 491 additions & 26 deletions

File tree

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,29 @@ class Start_Agentflow implements INode {
213213
startInputType: 'webhookTrigger'
214214
}
215215
},
216+
{
217+
label: 'Callback URL',
218+
name: 'callbackUrl',
219+
type: 'string',
220+
description:
221+
'If set, Flowise returns 202 immediately and POSTs the result to this URL when the flow finishes. Useful for platforms with strict HTTP timeout windows (GitHub, Slack, Zapier).',
222+
placeholder: 'https://example.com/flowise-callback',
223+
optional: true,
224+
show: {
225+
startInputType: 'webhookTrigger'
226+
}
227+
},
228+
{
229+
label: 'Callback Secret',
230+
name: 'callbackSecret',
231+
type: 'string',
232+
description:
233+
'If set, outgoing callback POSTs are signed with HMAC-SHA256. The signature is sent as X-Flowise-Signature: sha256=<hex> so your callback endpoint can verify the request came from Flowise.',
234+
optional: true,
235+
show: {
236+
startInputType: 'webhookTrigger'
237+
}
238+
},
216239
{
217240
label: 'Expected Query Parameters',
218241
name: 'webhookQueryParams',

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

Lines changed: 228 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Request, Response, NextFunction } from 'express'
33

44
const mockValidateWebhookChatflow = jest.fn()
55
const mockBuildChatflow = jest.fn()
6+
const mockDispatchCallback = jest.fn()
67

78
jest.mock('../../services/webhook', () => ({
89
__esModule: true,
@@ -19,6 +20,10 @@ jest.mock('../../utils/rateLimit', () => ({
1920
})
2021
}
2122
}))
23+
jest.mock('../../utils/callbackDispatcher', () => ({
24+
dispatchCallback: mockDispatchCallback
25+
}))
26+
jest.mock('uuid', () => ({ v4: () => 'generated-uuid' }))
2227

2328
import webhookController from './index'
2429

@@ -36,6 +41,7 @@ const mockReq = (overrides: Partial<Request> = {}): Request =>
3641
const mockRes = (): Response => {
3742
const res = {} as Response
3843
res.json = jest.fn().mockReturnValue(res)
44+
res.status = jest.fn().mockReturnValue(res)
3945
return res
4046
}
4147

@@ -44,6 +50,8 @@ const mockNext = (): NextFunction => jest.fn()
4450
describe('createWebhook', () => {
4551
beforeEach(() => {
4652
jest.clearAllMocks()
53+
// Default: no callback config on Start node
54+
mockValidateWebhookChatflow.mockResolvedValue({})
4755
})
4856

4957
it('calls next with PRECONDITION_FAILED when id is missing', async () => {
@@ -70,7 +78,6 @@ describe('createWebhook', () => {
7078
})
7179

7280
it('wraps req.body under webhook key before calling buildChatflow', async () => {
73-
mockValidateWebhookChatflow.mockResolvedValue(undefined)
7481
mockBuildChatflow.mockResolvedValue({})
7582

7683
const originalBody = { foo: 'bar' }
@@ -94,7 +101,6 @@ describe('createWebhook', () => {
94101
})
95102

96103
it('builds namespaced webhook payload with body, headers, and query', async () => {
97-
mockValidateWebhookChatflow.mockResolvedValue(undefined)
98104
mockBuildChatflow.mockResolvedValue({})
99105

100106
const req = mockReq({
@@ -121,7 +127,6 @@ describe('createWebhook', () => {
121127
})
122128

123129
it('returns buildChatflow result as JSON response', async () => {
124-
mockValidateWebhookChatflow.mockResolvedValue(undefined)
125130
const apiResult = { output: 'ok' }
126131
mockBuildChatflow.mockResolvedValue(apiResult)
127132

@@ -136,7 +141,6 @@ describe('createWebhook', () => {
136141
})
137142

138143
it('calls next with error when buildChatflow rejects', async () => {
139-
mockValidateWebhookChatflow.mockResolvedValue(undefined)
140144
const error = new Error('execution failed')
141145
mockBuildChatflow.mockRejectedValue(error)
142146

@@ -150,7 +154,6 @@ describe('createWebhook', () => {
150154
})
151155

152156
it('passes the original body to validateWebhookChatflow before mutation', async () => {
153-
mockValidateWebhookChatflow.mockResolvedValue(undefined)
154157
mockBuildChatflow.mockResolvedValue({})
155158

156159
const req = mockReq({ body: { foo: 'bar' } })
@@ -166,7 +169,226 @@ describe('createWebhook', () => {
166169
'POST',
167170
expect.any(Object),
168171
expect.any(Object),
169-
undefined // rawBody — not set on mock request
172+
undefined, // rawBody — not set on mock request
173+
undefined // options — not a resume call
174+
)
175+
})
176+
177+
it('passes skipFieldValidation option when body contains humanInput (resume call)', async () => {
178+
mockBuildChatflow.mockResolvedValue({})
179+
180+
const req = mockReq({ body: { chatId: 'abc', humanInput: { type: 'proceed', startNodeId: 'humanInputAgentflow_0' } } })
181+
const res = mockRes()
182+
const next = mockNext()
183+
184+
await webhookController.createWebhook(req, res, next)
185+
186+
expect(mockValidateWebhookChatflow).toHaveBeenCalledWith(
187+
'chatflow-123',
188+
undefined,
189+
expect.objectContaining({ humanInput: expect.any(Object) }),
190+
'POST',
191+
expect.any(Object),
192+
expect.any(Object),
193+
undefined,
194+
{ skipFieldValidation: true }
195+
)
196+
})
197+
198+
it('includes humanInput and chatId at top level of req.body on resume', async () => {
199+
mockBuildChatflow.mockResolvedValue({})
200+
201+
const humanInput = { type: 'proceed', startNodeId: 'humanInputAgentflow_0' }
202+
const req = mockReq({ body: { chatId: 'abc123', humanInput } })
203+
const res = mockRes()
204+
const next = mockNext()
205+
206+
await webhookController.createWebhook(req, res, next)
207+
208+
expect(mockBuildChatflow).toHaveBeenCalledWith(
209+
expect.objectContaining({
210+
body: expect.objectContaining({
211+
humanInput,
212+
chatId: 'abc123',
213+
webhook: expect.any(Object)
214+
})
215+
})
216+
)
217+
})
218+
219+
// --- Async callback (FLOWISE-367) ---
220+
221+
it('returns 202 immediately when X-Callback-Url header is present', async () => {
222+
mockBuildChatflow.mockResolvedValue({ text: 'done' })
223+
mockDispatchCallback.mockResolvedValue(undefined)
224+
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
225+
226+
const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
227+
const res = mockRes()
228+
const next = mockNext()
229+
230+
await webhookController.createWebhook(req, res, next)
231+
232+
expect(res.status).toHaveBeenCalledWith(202)
233+
expect(res.json).toHaveBeenCalledWith({ chatId: expect.any(String), status: 'PROCESSING' })
234+
expect(mockBuildChatflow).toHaveBeenCalled()
235+
})
236+
237+
it('returns 202 with chatId from body when already provided', async () => {
238+
mockBuildChatflow.mockResolvedValue({ text: 'done' })
239+
mockDispatchCallback.mockResolvedValue(undefined)
240+
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
241+
242+
const req = mockReq({
243+
body: { chatId: 'existing-id', foo: 'bar' },
244+
headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any
245+
})
246+
const res = mockRes()
247+
const next = mockNext()
248+
249+
await webhookController.createWebhook(req, res, next)
250+
251+
expect(res.json).toHaveBeenCalledWith({ chatId: 'existing-id', status: 'PROCESSING' })
252+
})
253+
254+
it('generates a chatId when not in body and callback URL is present', async () => {
255+
mockBuildChatflow.mockResolvedValue({ text: 'done' })
256+
mockDispatchCallback.mockResolvedValue(undefined)
257+
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
258+
259+
const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
260+
const res = mockRes()
261+
const next = mockNext()
262+
263+
await webhookController.createWebhook(req, res, next)
264+
265+
expect(res.json).toHaveBeenCalledWith({ chatId: 'generated-uuid', status: 'PROCESSING' })
266+
})
267+
268+
it('dispatches SUCCESS callback when flow completes without action', async () => {
269+
const apiResponse = { text: 'hello', executionId: 'exec-1' }
270+
mockBuildChatflow.mockResolvedValue(apiResponse)
271+
mockDispatchCallback.mockResolvedValue(undefined)
272+
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
273+
274+
const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
275+
const res = mockRes()
276+
277+
await webhookController.createWebhook(req, res, mockNext())
278+
279+
expect(mockDispatchCallback).toHaveBeenCalledWith(
280+
'https://cb.example.com',
281+
{ status: 'SUCCESS', chatId: expect.any(String), data: apiResponse },
282+
undefined
170283
)
171284
})
285+
286+
it('dispatches STOPPED callback when flow has action (HITL pause)', async () => {
287+
const action = { id: 'act-1', mapping: { approve: 'Proceed', reject: 'Reject' }, elements: [] }
288+
const apiResponse = { text: 'waiting', executionId: 'exec-2', action }
289+
mockBuildChatflow.mockResolvedValue(apiResponse)
290+
mockDispatchCallback.mockResolvedValue(undefined)
291+
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
292+
293+
const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
294+
const res = mockRes()
295+
296+
await webhookController.createWebhook(req, res, mockNext())
297+
298+
expect(mockDispatchCallback).toHaveBeenCalledWith(
299+
'https://cb.example.com',
300+
{
301+
status: 'STOPPED',
302+
chatId: expect.any(String),
303+
data: { text: 'waiting', executionId: 'exec-2', action }
304+
},
305+
undefined
306+
)
307+
})
308+
309+
it('dispatches ERROR callback when flow throws', async () => {
310+
mockBuildChatflow.mockRejectedValue(new Error('flow exploded'))
311+
mockDispatchCallback.mockResolvedValue(undefined)
312+
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
313+
314+
const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
315+
const res = mockRes()
316+
317+
await webhookController.createWebhook(req, res, mockNext())
318+
319+
expect(mockDispatchCallback).toHaveBeenCalledWith(
320+
'https://cb.example.com',
321+
{ status: 'ERROR', chatId: expect.any(String), error: 'flow exploded' },
322+
undefined
323+
)
324+
})
325+
326+
it('uses callbackSecret from Start node config when signing', async () => {
327+
mockValidateWebhookChatflow.mockResolvedValue({ callbackSecret: 'node-secret' })
328+
mockBuildChatflow.mockResolvedValue({ text: 'done' })
329+
mockDispatchCallback.mockResolvedValue(undefined)
330+
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
331+
332+
const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
333+
const res = mockRes()
334+
335+
await webhookController.createWebhook(req, res, mockNext())
336+
337+
expect(mockDispatchCallback).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 'node-secret')
338+
})
339+
340+
it('uses callbackUrl from Start node config when no header is present', async () => {
341+
mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://node-configured.example.com/cb' })
342+
mockBuildChatflow.mockResolvedValue({ text: 'done' })
343+
mockDispatchCallback.mockResolvedValue(undefined)
344+
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
345+
346+
const req = mockReq()
347+
const res = mockRes()
348+
349+
await webhookController.createWebhook(req, res, mockNext())
350+
351+
expect(res.status).toHaveBeenCalledWith(202)
352+
expect(mockDispatchCallback).toHaveBeenCalledWith('https://node-configured.example.com/cb', expect.any(Object), undefined)
353+
})
354+
355+
it('header callbackUrl takes priority over Start node config', async () => {
356+
mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://node.example.com/cb' })
357+
mockBuildChatflow.mockResolvedValue({ text: 'done' })
358+
mockDispatchCallback.mockResolvedValue(undefined)
359+
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
360+
361+
const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://header.example.com/cb' } as any })
362+
const res = mockRes()
363+
364+
await webhookController.createWebhook(req, res, mockNext())
365+
366+
expect(mockDispatchCallback).toHaveBeenCalledWith('https://header.example.com/cb', expect.any(Object), undefined)
367+
})
368+
369+
it('calls next with BAD_REQUEST when callbackUrl is not a valid http/https URL', async () => {
370+
const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'ftp://bad.example.com' } as any })
371+
const res = mockRes()
372+
const next = mockNext()
373+
374+
await webhookController.createWebhook(req, res, next)
375+
376+
expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: StatusCodes.BAD_REQUEST }))
377+
expect(mockBuildChatflow).not.toHaveBeenCalled()
378+
})
379+
380+
it('falls back to synchronous response when no callbackUrl is configured', async () => {
381+
const apiResult = { text: 'sync result' }
382+
mockBuildChatflow.mockResolvedValue(apiResult)
383+
384+
const req = mockReq()
385+
const res = mockRes()
386+
const next = mockNext()
387+
388+
await webhookController.createWebhook(req, res, next)
389+
390+
expect(res.status).not.toHaveBeenCalledWith(202)
391+
expect(res.json).toHaveBeenCalledWith(apiResult)
392+
expect(mockDispatchCallback).not.toHaveBeenCalled()
393+
})
172394
})

0 commit comments

Comments
 (0)