Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions packages/components/nodes/agentflow/Start/Start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,29 @@ class Start_Agentflow implements INode {
startInputType: 'webhookTrigger'
}
},
{
label: 'Callback URL',
name: 'callbackUrl',
type: 'string',
description:
'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).',
placeholder: 'https://example.com/flowise-callback',
optional: true,
show: {
startInputType: 'webhookTrigger'
}
},
{
label: 'Callback Secret',
name: 'callbackSecret',
type: 'string',
description:
'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.',
optional: true,
show: {
startInputType: 'webhookTrigger'
}
},
{
label: 'Expected Query Parameters',
name: 'webhookQueryParams',
Expand Down
234 changes: 228 additions & 6 deletions packages/server/src/controllers/webhook/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Request, Response, NextFunction } from 'express'

const mockValidateWebhookChatflow = jest.fn()
const mockBuildChatflow = jest.fn()
const mockDispatchCallback = jest.fn()

jest.mock('../../services/webhook', () => ({
__esModule: true,
Expand All @@ -19,6 +20,10 @@ jest.mock('../../utils/rateLimit', () => ({
})
}
}))
jest.mock('../../utils/callbackDispatcher', () => ({
dispatchCallback: mockDispatchCallback
}))
jest.mock('uuid', () => ({ v4: () => 'generated-uuid' }))

import webhookController from './index'

Expand All @@ -36,6 +41,7 @@ const mockReq = (overrides: Partial<Request> = {}): Request =>
const mockRes = (): Response => {
const res = {} as Response
res.json = jest.fn().mockReturnValue(res)
res.status = jest.fn().mockReturnValue(res)
return res
}

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

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

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

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

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

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

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

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

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

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

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

const req = mockReq({ body: { foo: 'bar' } })
Expand All @@ -166,7 +169,226 @@ describe('createWebhook', () => {
'POST',
expect.any(Object),
expect.any(Object),
undefined // rawBody — not set on mock request
undefined, // rawBody — not set on mock request
undefined // options — not a resume call
)
})

it('passes skipFieldValidation option when body contains humanInput (resume call)', async () => {
mockBuildChatflow.mockResolvedValue({})

const req = mockReq({ body: { chatId: 'abc', humanInput: { type: 'proceed', startNodeId: 'humanInputAgentflow_0' } } })
const res = mockRes()
const next = mockNext()

await webhookController.createWebhook(req, res, next)

expect(mockValidateWebhookChatflow).toHaveBeenCalledWith(
'chatflow-123',
undefined,
expect.objectContaining({ humanInput: expect.any(Object) }),
'POST',
expect.any(Object),
expect.any(Object),
undefined,
{ skipFieldValidation: true }
)
})

it('includes humanInput and chatId at top level of req.body on resume', async () => {
mockBuildChatflow.mockResolvedValue({})

const humanInput = { type: 'proceed', startNodeId: 'humanInputAgentflow_0' }
const req = mockReq({ body: { chatId: 'abc123', humanInput } })
const res = mockRes()
const next = mockNext()

await webhookController.createWebhook(req, res, next)

expect(mockBuildChatflow).toHaveBeenCalledWith(
expect.objectContaining({
body: expect.objectContaining({
humanInput,
chatId: 'abc123',
webhook: expect.any(Object)
})
})
)
})

// --- Async callback (FLOWISE-367) ---

it('returns 202 immediately when X-Callback-Url header is present', async () => {
mockBuildChatflow.mockResolvedValue({ text: 'done' })
mockDispatchCallback.mockResolvedValue(undefined)
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())

const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
const res = mockRes()
const next = mockNext()

await webhookController.createWebhook(req, res, next)

expect(res.status).toHaveBeenCalledWith(202)
expect(res.json).toHaveBeenCalledWith({ chatId: expect.any(String), status: 'PROCESSING' })
expect(mockBuildChatflow).toHaveBeenCalled()
})

it('returns 202 with chatId from body when already provided', async () => {
mockBuildChatflow.mockResolvedValue({ text: 'done' })
mockDispatchCallback.mockResolvedValue(undefined)
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())

const req = mockReq({
body: { chatId: 'existing-id', foo: 'bar' },
headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any
})
const res = mockRes()
const next = mockNext()

await webhookController.createWebhook(req, res, next)

expect(res.json).toHaveBeenCalledWith({ chatId: 'existing-id', status: 'PROCESSING' })
})

it('generates a chatId when not in body and callback URL is present', async () => {
mockBuildChatflow.mockResolvedValue({ text: 'done' })
mockDispatchCallback.mockResolvedValue(undefined)
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())

const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
const res = mockRes()
const next = mockNext()

await webhookController.createWebhook(req, res, next)

expect(res.json).toHaveBeenCalledWith({ chatId: 'generated-uuid', status: 'PROCESSING' })
})

it('dispatches SUCCESS callback when flow completes without action', async () => {
const apiResponse = { text: 'hello', executionId: 'exec-1' }
mockBuildChatflow.mockResolvedValue(apiResponse)
mockDispatchCallback.mockResolvedValue(undefined)
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())

const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
const res = mockRes()

await webhookController.createWebhook(req, res, mockNext())

expect(mockDispatchCallback).toHaveBeenCalledWith(
'https://cb.example.com',
{ status: 'SUCCESS', chatId: expect.any(String), data: apiResponse },
undefined
)
})

it('dispatches STOPPED callback when flow has action (HITL pause)', async () => {
const action = { id: 'act-1', mapping: { approve: 'Proceed', reject: 'Reject' }, elements: [] }
const apiResponse = { text: 'waiting', executionId: 'exec-2', action }
mockBuildChatflow.mockResolvedValue(apiResponse)
mockDispatchCallback.mockResolvedValue(undefined)
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())

const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
const res = mockRes()

await webhookController.createWebhook(req, res, mockNext())

expect(mockDispatchCallback).toHaveBeenCalledWith(
'https://cb.example.com',
{
status: 'STOPPED',
chatId: expect.any(String),
data: { text: 'waiting', executionId: 'exec-2', action }
},
undefined
)
})

it('dispatches ERROR callback when flow throws', async () => {
mockBuildChatflow.mockRejectedValue(new Error('flow exploded'))
mockDispatchCallback.mockResolvedValue(undefined)
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())

const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
const res = mockRes()

await webhookController.createWebhook(req, res, mockNext())

expect(mockDispatchCallback).toHaveBeenCalledWith(
'https://cb.example.com',
{ status: 'ERROR', chatId: expect.any(String), error: 'flow exploded' },
undefined
)
})

it('uses callbackSecret from Start node config when signing', async () => {
mockValidateWebhookChatflow.mockResolvedValue({ callbackSecret: 'node-secret' })
mockBuildChatflow.mockResolvedValue({ text: 'done' })
mockDispatchCallback.mockResolvedValue(undefined)
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())

const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://cb.example.com' } as any })
const res = mockRes()

await webhookController.createWebhook(req, res, mockNext())

expect(mockDispatchCallback).toHaveBeenCalledWith(expect.any(String), expect.any(Object), 'node-secret')
})

it('uses callbackUrl from Start node config when no header is present', async () => {
mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://node-configured.example.com/cb' })
mockBuildChatflow.mockResolvedValue({ text: 'done' })
mockDispatchCallback.mockResolvedValue(undefined)
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())

const req = mockReq()
const res = mockRes()

await webhookController.createWebhook(req, res, mockNext())

expect(res.status).toHaveBeenCalledWith(202)
expect(mockDispatchCallback).toHaveBeenCalledWith('https://node-configured.example.com/cb', expect.any(Object), undefined)
})

it('header callbackUrl takes priority over Start node config', async () => {
mockValidateWebhookChatflow.mockResolvedValue({ callbackUrl: 'https://node.example.com/cb' })
mockBuildChatflow.mockResolvedValue({ text: 'done' })
mockDispatchCallback.mockResolvedValue(undefined)
jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())

const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'https://header.example.com/cb' } as any })
const res = mockRes()

await webhookController.createWebhook(req, res, mockNext())

expect(mockDispatchCallback).toHaveBeenCalledWith('https://header.example.com/cb', expect.any(Object), undefined)
})

it('calls next with BAD_REQUEST when callbackUrl is not a valid http/https URL', async () => {
const req = mockReq({ headers: { 'content-type': 'application/json', 'x-callback-url': 'ftp://bad.example.com' } as any })
const res = mockRes()
const next = mockNext()

await webhookController.createWebhook(req, res, next)

expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: StatusCodes.BAD_REQUEST }))
expect(mockBuildChatflow).not.toHaveBeenCalled()
})

it('falls back to synchronous response when no callbackUrl is configured', async () => {
const apiResult = { text: 'sync result' }
mockBuildChatflow.mockResolvedValue(apiResult)

const req = mockReq()
const res = mockRes()
const next = mockNext()

await webhookController.createWebhook(req, res, next)

expect(res.status).not.toHaveBeenCalledWith(202)
expect(res.json).toHaveBeenCalledWith(apiResult)
expect(mockDispatchCallback).not.toHaveBeenCalled()
})
})
Loading