diff --git a/packages/agentflow/README.md b/packages/agentflow/README.md
index f323f76411f..97f17c36b79 100644
--- a/packages/agentflow/README.md
+++ b/packages/agentflow/README.md
@@ -1,270 +1,270 @@
-# @flowiseai/agentflow
-
-[](https://www.npmjs.com/package/@flowiseai/agentflow)
-[](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md)
-
-> Embeddable React component for building and visualizing AI agent workflows
-
-## Status: Dev
-
-**Current version: `0.0.0-dev.13`**
-
-This package is functional and has comprehensive test coverage, but the public API may still change before a stable release. It is suitable for early integration and testing but not yet recommended for production use.
-
-**What works today:**
-
-- 13 node types with full editing support (Loop and Human Input nodes are present in the palette but not yet fully verified end-to-end)
-- Connection validation, flow validation, and export
-- Async option loading (models, credentials, tools)
-- Variable picker and field visibility conditions
-- AI flow generation from natural language
-- Dark/light mode theming
-- Read-only mode and custom rendering via render props
-
-## Overview
-
-`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes.
-
-## Features
-
-- **Visual Canvas** — Drag-and-drop flow editor built on ReactFlow with zoom, pan, minimap, and fit-to-view controls
-- **13 Built-in Node Types** — Start, Agent, LLM, Condition, Condition Agent, Direct Reply, Custom Function, Tool, Retriever, Sticky Note, HTTP, Iteration, and Execute Flow
-- **Node Editor Dialog** — Modal for editing node parameters with dynamic input types (text, number, boolean, dropdown, arrays, JSON, code, variable selector, async options)
-- **Credential Management** — Create and edit credentials inline from the node editor
-- **Rich Text Editor** — TipTap-based editor with syntax highlighting for JavaScript, TypeScript, Python, and JSON (lazy-loaded)
-- **Specialized Input Components** — Condition builder, messages input (role + content), and structured output schema builder
-- **AI Flow Generator** — Generate flows from natural language descriptions with model selection
-- **Flow Validation** — Detects empty flows, missing start nodes, disconnected nodes, cycles, hanging edges, and per-node input errors with visual feedback
-- **Dark Mode** — Full light/dark theme support via design tokens and CSS variables
-- **Read-Only Mode** — Disable editing for view-only embedding
-- **Custom Rendering** — Replace the default header and node palette with your own components via render props
-- **Imperative API** — Programmatic control via ref (`getFlow`, `validate`, `fitView`, `clear`, `addNode`, `toJSON`)
-- **Request Interceptor** — Customize outgoing API requests (headers, credentials) via an Axios interceptor callback
-- **Keyboard Shortcuts** — Cmd/Ctrl+S to save
-
-## Installation
-
-```bash
-pnpm add @flowiseai/agentflow
-```
-
-**Peer Dependencies:**
-
-```bash
-pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow
-```
-
-## Basic Usage
-
-```tsx
-import { Agentflow } from '@flowiseai/agentflow'
-
-import '@flowiseai/agentflow/flowise.css'
-
-export default function App() {
- return (
-
- )
-}
-```
-
-### With Initial Flow Data and Callbacks
-
-```tsx
-import { useRef } from 'react'
-
-import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow'
-
-import '@flowiseai/agentflow/flowise.css'
-
-export default function App() {
- const ref = useRef(null)
-
- const initialFlow: FlowData = {
- nodes: [
- {
- id: 'startAgentflow_0',
- type: 'agentflowNode',
- position: { x: 100, y: 100 },
- data: {
- id: 'startAgentflow_0',
- name: 'startAgentflow',
- label: 'Start',
- color: '#7EE787',
- hideInput: true,
- outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
- }
- }
- ],
- edges: [],
- viewport: { x: 0, y: 0, zoom: 1 }
- }
-
- return (
-
-
console.log('Flow changed:', flow)}
- onSave={(flow) => console.log('Flow saved:', flow)}
- />
-
- )
-}
-```
-
-### More Examples
-
-The [examples app](./examples/README.md) includes working demos for:
-
-- **Variable usage** — `{{variable}}` syntax, variable picker, available sources
-- **Async options** — Loading models, tools, and credentials from the API
-- **Status indicators** — Node execution states (running, finished, error, stopped)
-- **Field visibility** — Show/hide conditions on node inputs
-- **State management** — Dirty tracking, flow change callbacks
-- **Dark mode** — Light/dark theme toggle
-- **Custom UI** — Custom header and node palette via render props
-- **Filtered components** — Restricting available node types with presets
-- **Validation actions** — Validation button, error display, and custom `canvasActions` alongside built-in controls
-
-Run `cd examples && npm install && npm run dev` to try them locally.
-
-## Props
-
-
-| Prop | Type | Default | Description |
-| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- |
-| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint |
-| `token` | `string` | — | Authentication token for API calls |
-| `requestInterceptor` | `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig` | — | Customize outgoing API requests (e.g., set `withCredentials`, add headers). The callback receives the full Axios request config — only modify what you need. See [Security: requestInterceptor](#security-requestinterceptor) below. |
-| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) |
-| `flowId` | `string` | — | Flow identifier (reserved for future use) |
-| `components` | `string[]` | — | Restrict which node types appear in the palette |
-| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) |
-| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save |
-| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI |
-| `isDarkMode` | `boolean` | `false` | Use dark mode theme |
-| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) |
-| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) |
-| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette |
-| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button |
-| `canvasActions` | `ReactNode` | — | Additional content rendered in the top-right canvas overlay, to the right of the built-in validation FAB. Hidden when `readOnly` is true. |
-| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer |
-| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer |
-
-### Imperative Methods (via `ref`)
-
-| Method | Return Type | Description |
-| ------------------------ | ------------------------- | ------------------------------------- |
-| `getFlow()` | `FlowData` | Get current flow data |
-| `toJSON()` | `string` | Export flow as JSON string |
-| `validate()` | `ValidationResult` | Validate the current flow |
-| `fitView()` | `void` | Fit all nodes into view |
-| `clear()` | `void` | Remove all nodes and edges |
-| `addNode(nodeData)` | `void` | Add a node (`Partial`) |
-| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance |
-
-### Security: `requestInterceptor`
-
-The `requestInterceptor` callback runs inside the Axios request pipeline and has access to the full request configuration, including authentication headers. This is the same trust model as any other callback prop (e.g., `onSave`, `renderHeader`) — the host application developer supplies the function and is responsible for its behavior.
-
-**Guidelines for consumers:**
-
-- Only pass **trusted, developer-authored** functions. Never use dynamically evaluated code (`eval`, `new Function`, etc.) or user-generated input as the interceptor.
-- Follow the **principle of least privilege** — only read or modify the specific config properties you need (e.g., `withCredentials`, custom headers).
-- If the interceptor throws, the error is caught, logged, and the **original unmodified config** is used so the request still proceeds safely.
-
-### Node Types
-
-The following node types are available in the palette by default. Use the `components` prop to restrict which types are shown.
-
-
-| Node Type | Description |
-| -------------------------- | ------------------------------------ |
-| `startAgentflow` | Entry point (required, always shown) |
-| `agentAgentflow` | AI agent execution |
-| `llmAgentflow` | LLM / language model call |
-| `conditionAgentflow` | Conditional branching |
-| `conditionAgentAgentflow` | Agent-level conditional branching |
-| `directReplyAgentflow` | Direct response to user |
-| `customFunctionAgentflow` | Custom JavaScript function |
-| `toolAgentflow` | Tool integration |
-| `retrieverAgentflow` | Data retrieval |
-| `stickyNoteAgentflow` | Canvas annotation (not connectable) |
-| `httpAgentflow` | HTTP request |
-| `iterationAgentflow` | Iteration / map-reduce container |
-| `executeFlowAgentflow` | Execute a sub-flow |
-
-### Design Note
-
-`` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes.
-
-## Development
-
-```bash
-# Install dependencies
-pnpm install
-
-# Build the package
-pnpm build
-
-# Run tests
-pnpm test
-
-# Run examples
-cd examples && pnpm install && pnpm dev
-```
-
-Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status.
-
-## Troubleshooting
-
-### API Connection Issues
-
-- **CORS errors** — Ensure the Flowise server allows requests from your app's origin. Check the Flowise CORS configuration.
-- **401 Unauthorized** — Use an API Key (Settings > API Keys), not a JWT user token. Verify the key is passed via the `token` prop.
-- **Wrong `apiBaseUrl`** — The URL must point to the Flowise API root (e.g., `http://localhost:3000`), not a subpath.
-
-### Validation Errors
-
-- **"Flow is empty"** — Add at least one node to the canvas.
-- **"Missing start node"** — Every flow requires a `startAgentflow` node. Add one from the palette.
-- **"Disconnected nodes"** — All non-sticky-note nodes must be reachable from the start node. Connect any orphaned nodes.
-- **"Cycle detected"** — Flows must be acyclic (DAGs). Remove the edge that creates the cycle.
-
-### Theme Issues
-
-- **Dark mode not applying** — Pass `isDarkMode={true}` as a prop. The component uses its own design tokens and does not inherit from the host app's theme.
-- **Style conflicts** — Ensure `@flowiseai/agentflow/flowise.css` is imported. The component's CSS variables are scoped to avoid collisions.
-
-### Variables Not Resolving
-
-- **Variable not in picker** — Variables are sourced from upstream nodes. Ensure the source node is connected and upstream of the current node.
-- **Incorrect path** — Variable syntax is `{{nodeName.outputKey}}`. Check that the node name and output key match exactly.
-
-### Async Options Not Loading
-
-- **Empty dropdowns for models/tools/credentials** — These load from the Flowise API. Verify `apiBaseUrl` and `token` are correct and the server is running.
-- **Network errors in console** — Check browser DevTools for failed requests. The API client logs errors to the console.
-
-## Documentation
-
-- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
-- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration
-- [Examples](./examples/README.md) - Usage examples and demos
-
-## Contributing
-
-This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines.
-
-## License
-
-Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details.
-
----
-
-Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem
+# @flowiseai/agentflow
+
+[](https://www.npmjs.com/package/@flowiseai/agentflow)
+[](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md)
+
+> Embeddable React component for building and visualizing AI agent workflows
+
+## Status: Dev
+
+**Current version: `0.0.0-dev.13`**
+
+This package is functional and has comprehensive test coverage, but the public API may still change before a stable release. It is suitable for early integration and testing but not yet recommended for production use.
+
+**What works today:**
+
+- 13 node types with full editing support (Loop and Human Input nodes are present in the palette but not yet fully verified end-to-end)
+- Connection validation, flow validation, and export
+- Async option loading (models, credentials, tools)
+- Variable picker and field visibility conditions
+- AI flow generation from natural language
+- Dark/light mode theming
+- Read-only mode and custom rendering via render props
+
+## Overview
+
+`@flowiseai/agentflow` is a React-based flow editor for creating AI agent workflows. It provides a visual canvas built on ReactFlow for connecting AI agents, LLMs, tools, and logic nodes.
+
+## Features
+
+- **Visual Canvas** — Drag-and-drop flow editor built on ReactFlow with zoom, pan, minimap, and fit-to-view controls
+- **13 Built-in Node Types** — Start, Agent, LLM, Condition, Condition Agent, Direct Reply, Custom Function, Tool, Retriever, Sticky Note, HTTP, Iteration, and Execute Flow
+- **Node Editor Dialog** — Modal for editing node parameters with dynamic input types (text, number, boolean, dropdown, arrays, JSON, code, variable selector, async options)
+- **Credential Management** — Create and edit credentials inline from the node editor
+- **Rich Text Editor** — TipTap-based editor with syntax highlighting for JavaScript, TypeScript, Python, and JSON (lazy-loaded)
+- **Specialized Input Components** — Condition builder, messages input (role + content), and structured output schema builder
+- **AI Flow Generator** — Generate flows from natural language descriptions with model selection
+- **Flow Validation** — Detects empty flows, missing start nodes, disconnected nodes, cycles, hanging edges, and per-node input errors with visual feedback
+- **Dark Mode** — Full light/dark theme support via design tokens and CSS variables
+- **Read-Only Mode** — Disable editing for view-only embedding
+- **Custom Rendering** — Replace the default header and node palette with your own components via render props
+- **Imperative API** — Programmatic control via ref (`getFlow`, `validate`, `fitView`, `clear`, `addNode`, `toJSON`)
+- **Request Interceptor** — Customize outgoing API requests (headers, credentials) via an Axios interceptor callback
+- **Keyboard Shortcuts** — Cmd/Ctrl+S to save
+
+## Installation
+
+```bash
+pnpm add @flowiseai/agentflow
+```
+
+**Peer Dependencies:**
+
+```bash
+pnpm add react react-dom @mui/material @mui/icons-material @emotion/react @emotion/styled reactflow
+```
+
+## Basic Usage
+
+```tsx
+import { Agentflow } from '@flowiseai/agentflow'
+
+import '@flowiseai/agentflow/flowise.css'
+
+export default function App() {
+ return (
+
+ )
+}
+```
+
+### With Initial Flow Data and Callbacks
+
+```tsx
+import { useRef } from 'react'
+
+import { Agentflow, type AgentFlowInstance, type FlowData } from '@flowiseai/agentflow'
+
+import '@flowiseai/agentflow/flowise.css'
+
+export default function App() {
+ const ref = useRef(null)
+
+ const initialFlow: FlowData = {
+ nodes: [
+ {
+ id: 'startAgentflow_0',
+ type: 'agentflowNode',
+ position: { x: 100, y: 100 },
+ data: {
+ id: 'startAgentflow_0',
+ name: 'startAgentflow',
+ label: 'Start',
+ color: '#7EE787',
+ hideInput: true,
+ outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
+ }
+ }
+ ],
+ edges: [],
+ viewport: { x: 0, y: 0, zoom: 1 }
+ }
+
+ return (
+
+
console.log('Flow changed:', flow)}
+ onSave={(flow) => console.log('Flow saved:', flow)}
+ />
+
+ )
+}
+```
+
+### More Examples
+
+The [examples app](./examples/README.md) includes working demos for:
+
+- **Variable usage** — `{{variable}}` syntax, variable picker, available sources
+- **Async options** — Loading models, tools, and credentials from the API
+- **Status indicators** — Node execution states (running, finished, error, stopped)
+- **Field visibility** — Show/hide conditions on node inputs
+- **State management** — Dirty tracking, flow change callbacks
+- **Dark mode** — Light/dark theme toggle
+- **Custom UI** — Custom header and node palette via render props
+- **Filtered components** — Restricting available node types with presets
+- **Validation actions** — Validation button, error display, and custom `canvasActions` alongside built-in controls
+
+Run `cd examples && npm install && npm run dev` to try them locally.
+
+## Props
+
+
+| Prop | Type | Default | Description |
+| -------------------- | ------------------------------------------ | -------------- | --------------------------------------------------------------- |
+| `apiBaseUrl` | `string` | **(required)** | Flowise API server endpoint |
+| `token` | `string` | — | Authentication token for API calls |
+| `requestInterceptor` | `(config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig` | — | Customize outgoing API requests (e.g., set `withCredentials`, add headers). The callback receives the full Axios request config — only modify what you need. See [Security: requestInterceptor](#security-requestinterceptor) below. |
+| `initialFlow` | `FlowData` | — | Initial flow data to render (uncontrolled — only used on mount) |
+| `flowId` | `string` | — | Flow identifier (reserved for future use) |
+| `components` | `string[]` | — | Restrict which node types appear in the palette |
+| `onFlowChange` | `(flow: FlowData) => void` | — | Called when the flow changes (node/edge add, remove, move) |
+| `onSave` | `(flow: FlowData) => void` | — | Called when the user triggers a save |
+| `onFlowGenerated` | `(flow: FlowData) => void` | — | Called when a flow is generated via AI |
+| `isDarkMode` | `boolean` | `false` | Use dark mode theme |
+| `readOnly` | `boolean` | `false` | Disable editing (nodes not draggable/connectable) |
+| `showDefaultHeader` | `boolean` | `true` | Show built-in header (ignored if `renderHeader` provided) |
+| `showDefaultPalette` | `boolean` | `true` | Show built-in node palette |
+| `enableGenerator` | `boolean` | `true` | Show the AI flow generator button |
+| `canvasActions` | `ReactNode` | — | Additional content rendered in the top-right canvas overlay, to the right of the built-in validation FAB. Hidden when `readOnly` is true. |
+| `renderHeader` | `(props: HeaderRenderProps) => ReactNode` | — | Custom header renderer |
+| `renderNodePalette` | `(props: PaletteRenderProps) => ReactNode` | — | Custom node palette renderer |
+
+### Imperative Methods (via `ref`)
+
+| Method | Return Type | Description |
+| ------------------------ | ------------------------- | ------------------------------------- |
+| `getFlow()` | `FlowData` | Get current flow data |
+| `toJSON()` | `string` | Export flow as JSON string |
+| `validate()` | `ValidationResult` | Validate the current flow |
+| `fitView()` | `void` | Fit all nodes into view |
+| `clear()` | `void` | Remove all nodes and edges |
+| `addNode(nodeData)` | `void` | Add a node (`Partial`) |
+| `getReactFlowInstance()` | `ReactFlowInstance\|null` | Get the underlying ReactFlow instance |
+
+### Security: `requestInterceptor`
+
+The `requestInterceptor` callback runs inside the Axios request pipeline and has access to the full request configuration, including authentication headers. This is the same trust model as any other callback prop (e.g., `onSave`, `renderHeader`) — the host application developer supplies the function and is responsible for its behavior.
+
+**Guidelines for consumers:**
+
+- Only pass **trusted, developer-authored** functions. Never use dynamically evaluated code (`eval`, `new Function`, etc.) or user-generated input as the interceptor.
+- Follow the **principle of least privilege** — only read or modify the specific config properties you need (e.g., `withCredentials`, custom headers).
+- If the interceptor throws, the error is caught, logged, and the **original unmodified config** is used so the request still proceeds safely.
+
+### Node Types
+
+The following node types are available in the palette by default. Use the `components` prop to restrict which types are shown.
+
+
+| Node Type | Description |
+| -------------------------- | ------------------------------------ |
+| `startAgentflow` | Entry point (required, always shown) |
+| `agentAgentflow` | AI agent execution |
+| `llmAgentflow` | LLM / language model call |
+| `conditionAgentflow` | Conditional branching |
+| `conditionAgentAgentflow` | Agent-level conditional branching |
+| `directReplyAgentflow` | Direct response to user |
+| `customFunctionAgentflow` | Custom JavaScript function |
+| `toolAgentflow` | Tool integration |
+| `retrieverAgentflow` | Data retrieval |
+| `stickyNoteAgentflow` | Canvas annotation (not connectable) |
+| `httpAgentflow` | HTTP request |
+| `iterationAgentflow` | Iteration / map-reduce container |
+| `executeFlowAgentflow` | Execute a sub-flow |
+
+### Design Note
+
+`` is an **uncontrolled component**. The `initialFlow` prop seeds the canvas state on mount, but the component owns its own state afterward. Use the `ref` for imperative access and `onFlowChange` to observe changes.
+
+## Development
+
+```bash
+# Install dependencies
+pnpm install
+
+# Build the package
+pnpm build
+
+# Run tests
+pnpm test
+
+# Run examples
+cd examples && pnpm install && pnpm dev
+```
+
+Visit the [examples](./examples) directory for more usage patterns. See [TESTS.md](./TESTS.md) for the full test plan and coverage status.
+
+## Troubleshooting
+
+### API Connection Issues
+
+- **CORS errors** — Ensure the Flowise server allows requests from your app's origin. Check the Flowise CORS configuration.
+- **401 Unauthorized** — Use an API Key (Settings > API Keys), not a JWT user token. Verify the key is passed via the `token` prop.
+- **Wrong `apiBaseUrl`** — The URL must point to the Flowise API root (e.g., `http://localhost:3000`), not a subpath.
+
+### Validation Errors
+
+- **"Flow is empty"** — Add at least one node to the canvas.
+- **"Missing start node"** — Every flow requires a `startAgentflow` node. Add one from the palette.
+- **"Disconnected nodes"** — All non-sticky-note nodes must be reachable from the start node. Connect any orphaned nodes.
+- **"Cycle detected"** — Flows must be acyclic (DAGs). Remove the edge that creates the cycle.
+
+### Theme Issues
+
+- **Dark mode not applying** — Pass `isDarkMode={true}` as a prop. The component uses its own design tokens and does not inherit from the host app's theme.
+- **Style conflicts** — Ensure `@flowiseai/agentflow/flowise.css` is imported. The component's CSS variables are scoped to avoid collisions.
+
+### Variables Not Resolving
+
+- **Variable not in picker** — Variables are sourced from upstream nodes. Ensure the source node is connected and upstream of the current node.
+- **Incorrect path** — Variable syntax is `{{nodeName.outputKey}}`. Check that the node name and output key match exactly.
+
+### Async Options Not Loading
+
+- **Empty dropdowns for models/tools/credentials** — These load from the Flowise API. Verify `apiBaseUrl` and `token` are correct and the server is running.
+- **Network errors in console** — Check browser DevTools for failed requests. The API client logs errors to the console.
+
+## Documentation
+
+- [ARCHITECTURE.md](./ARCHITECTURE.md) - Internal architecture and design patterns
+- [TESTS.md](./TESTS.md) - Test plan, coverage tiers, and configuration
+- [Examples](./examples/README.md) - Usage examples and demos
+
+## Contributing
+
+This package follows a feature-based architecture with clear separation of concerns. See [ARCHITECTURE.md](./ARCHITECTURE.md) for details on the project structure and development guidelines.
+
+## License
+
+Apache-2.0 — see the repository root [LICENSE.md](https://github.com/FlowiseAI/Flowise/blob/main/LICENSE.md) for details.
+
+---
+
+Part of the [Flowise](https://github.com/FlowiseAI/Flowise) ecosystem
diff --git a/packages/agentflow/src/core/types/node.ts b/packages/agentflow/src/core/types/node.ts
index b10e8d483b4..20ee866e4b2 100644
--- a/packages/agentflow/src/core/types/node.ts
+++ b/packages/agentflow/src/core/types/node.ts
@@ -87,7 +87,17 @@ export interface InputParam {
type: string
default?: unknown
optional?: boolean
- options?: Array<{ label: string; name: string; description?: string; client?: Array } | string>
+ options?: Array<
+ | {
+ label: string
+ name: string
+ description?: string
+ client?: Array
+ show?: Record
+ hide?: Record
+ }
+ | string
+ >
placeholder?: string
rows?: number
description?: string
diff --git a/packages/agentflow/src/core/utils/fieldVisibility.test.ts b/packages/agentflow/src/core/utils/fieldVisibility.test.ts
index 267de648cea..91080445852 100644
--- a/packages/agentflow/src/core/utils/fieldVisibility.test.ts
+++ b/packages/agentflow/src/core/utils/fieldVisibility.test.ts
@@ -200,6 +200,104 @@ describe('evaluateFieldVisibility', () => {
expect(params[0].display).toBeUndefined()
expect(params[1].display).toBeUndefined()
})
+
+ describe('option-level show/hide filtering', () => {
+ it('removes options whose hide condition matches', () => {
+ const param = makeParam({
+ type: 'options',
+ options: [
+ { label: 'String', name: 'string' },
+ { label: 'Object', name: 'object', hide: { contentType: 'application/x-www-form-urlencoded' } }
+ ] as any
+ })
+
+ const result = evaluateFieldVisibility([param], { contentType: 'application/x-www-form-urlencoded' })
+ expect(result[0].options).toHaveLength(1)
+ expect(result[0].options![0]).toMatchObject({ name: 'string' })
+ })
+
+ it('keeps options whose hide condition does not match', () => {
+ const param = makeParam({
+ type: 'options',
+ options: [
+ { label: 'String', name: 'string' },
+ { label: 'Object', name: 'object', hide: { contentType: 'application/x-www-form-urlencoded' } }
+ ] as any
+ })
+
+ const result = evaluateFieldVisibility([param], { contentType: 'application/json' })
+ expect(result[0].options).toHaveLength(2)
+ })
+
+ it('removes options whose show condition does not match', () => {
+ const param = makeParam({
+ type: 'options',
+ options: [
+ { label: 'Basic', name: 'basic' },
+ { label: 'Advanced', name: 'advanced', show: { mode: 'expert' } }
+ ] as any
+ })
+
+ const result = evaluateFieldVisibility([param], { mode: 'beginner' })
+ expect(result[0].options).toHaveLength(1)
+ expect(result[0].options![0]).toMatchObject({ name: 'basic' })
+ })
+
+ it('keeps options whose show condition matches', () => {
+ const param = makeParam({
+ type: 'options',
+ options: [
+ { label: 'Basic', name: 'basic' },
+ { label: 'Advanced', name: 'advanced', show: { mode: 'expert' } }
+ ] as any
+ })
+
+ const result = evaluateFieldVisibility([param], { mode: 'expert' })
+ expect(result[0].options).toHaveLength(2)
+ })
+
+ it('passes through string options unchanged', () => {
+ const param = makeParam({
+ type: 'options',
+ options: ['one', 'two', 'three'] as any
+ })
+
+ const result = evaluateFieldVisibility([param], {})
+ expect(result[0].options).toHaveLength(3)
+ })
+
+ it('passes through options with no show/hide unchanged', () => {
+ const param = makeParam({
+ type: 'options',
+ options: [
+ { label: 'A', name: 'a' },
+ { label: 'B', name: 'b' }
+ ] as any
+ })
+
+ const result = evaluateFieldVisibility([param], {})
+ expect(result[0].options).toHaveLength(2)
+ })
+
+ it('does not mutate the original options array', () => {
+ const options = [
+ { label: 'String', name: 'string' },
+ { label: 'Object', name: 'object', hide: { contentType: 'application/x-www-form-urlencoded' } }
+ ] as any
+ const param = makeParam({ type: 'options', options })
+
+ evaluateFieldVisibility([param], { contentType: 'application/x-www-form-urlencoded' })
+
+ // Original options array is untouched
+ expect(options).toHaveLength(2)
+ })
+
+ it('does not affect non-options params', () => {
+ const param = makeParam({ type: 'string' })
+ const result = evaluateFieldVisibility([param], {})
+ expect(result[0].options).toBeUndefined()
+ })
+ })
})
describe('evaluateFieldVisibility – nested array $index pattern (Start node formInputTypes)', () => {
diff --git a/packages/agentflow/src/core/utils/fieldVisibility.ts b/packages/agentflow/src/core/utils/fieldVisibility.ts
index 60250ebfdfb..9d3b76adafc 100644
--- a/packages/agentflow/src/core/utils/fieldVisibility.ts
+++ b/packages/agentflow/src/core/utils/fieldVisibility.ts
@@ -140,14 +140,28 @@ function inputValuesWithDeclaredDefaults(params: InputParam[], inputValues: Reco
/**
* Evaluate visibility for all params, returning new param objects with computed `display`.
+ * Also filters individual options within `type: 'options'` params based on their own show/hide conditions.
* Does not mutate the originals.
*/
export function evaluateFieldVisibility(params: InputParam[], inputValues: Record, arrayIndex?: number): InputParam[] {
const effectiveInputs = inputValuesWithDeclaredDefaults(params, inputValues)
- return params.map((param) => ({
- ...param,
- display: evaluateParamVisibility(param, effectiveInputs, arrayIndex)
- }))
+ return params.map((param) => {
+ const withDisplay = { ...param, display: evaluateParamVisibility(param, effectiveInputs, arrayIndex) }
+
+ if (withDisplay.type === 'options' && withDisplay.options) {
+ const filteredOptions = withDisplay.options.filter((opt) => {
+ if (typeof opt === 'string' || (!opt.show && !opt.hide)) return true
+ return evaluateParamVisibility(
+ { id: '', name: '', label: '', type: '', show: opt.show, hide: opt.hide },
+ effectiveInputs,
+ arrayIndex
+ )
+ })
+ return filteredOptions.length === withDisplay.options.length ? withDisplay : { ...withDisplay, options: filteredOptions }
+ }
+
+ return withDisplay
+ })
}
export function applyVisibleFieldDefaults(
diff --git a/packages/components/nodes/agentflow/Start/Start.ts b/packages/components/nodes/agentflow/Start/Start.ts
index e2ab91c75a3..6f3fc709162 100644
--- a/packages/components/nodes/agentflow/Start/Start.ts
+++ b/packages/components/nodes/agentflow/Start/Start.ts
@@ -48,7 +48,7 @@ class Start_Agentflow implements INode {
constructor() {
this.label = 'Start'
this.name = 'startAgentflow'
- this.version = 1.3
+ this.version = 1.4
this.type = 'Start'
this.category = 'Agent Flows'
this.description = 'Starting point of the agentflow'
@@ -72,6 +72,12 @@ class Start_Agentflow implements INode {
description: 'Start the workflow with form inputs',
client: ['agentflowv2']
},
+ {
+ label: 'Webhook Trigger',
+ name: 'webhookTrigger',
+ description: 'Trigger the workflow via an external webhook',
+ client: ['agentflowv2']
+ },
{
label: 'Schedule Input',
name: 'scheduleInput',
@@ -162,6 +168,330 @@ class Start_Agentflow implements INode {
}
]
},
+ {
+ label: 'HTTP Method',
+ name: 'webhookMethod',
+ type: 'options',
+ options: [
+ { label: 'GET', name: 'GET' },
+ { label: 'POST', name: 'POST' },
+ { label: 'PUT', name: 'PUT' },
+ { label: 'PATCH', name: 'PATCH' },
+ { label: 'DELETE', name: 'DELETE' }
+ ],
+ default: 'POST',
+ show: {
+ startInputType: 'webhookTrigger'
+ }
+ },
+ {
+ label: 'Content Type',
+ name: 'webhookContentType',
+ type: 'options',
+ description:
+ '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.',
+ options: [
+ { label: 'application/json', name: 'application/json' },
+ { label: 'application/x-www-form-urlencoded', name: 'application/x-www-form-urlencoded' }
+ ],
+ default: 'application/json',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger'
+ }
+ },
+ {
+ label: 'Webhook URL',
+ name: 'webhookURL',
+ type: 'string',
+ description: 'Send a request to this URL to trigger the workflow',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger'
+ }
+ },
+ {
+ label: 'Input Mode',
+ name: 'webhookInputMode',
+ type: 'options',
+ description: 'What this Start node passes as input to the rest of the flow when a webhook fires.',
+ options: [
+ {
+ label: 'Custom Text',
+ name: 'text',
+ description:
+ 'Pass a fixed string. Reference webhook fields with $webhook.body.* / $webhook.headers.* / $webhook.query.*'
+ },
+ {
+ label: 'No Input',
+ name: 'none',
+ description: 'Pass nothing. Use $webhook.* references inside downstream node configs to access the payload.'
+ },
+ {
+ label: 'Full Webhook Payload',
+ name: 'payload',
+ description:
+ 'Pass the full JSON-serialized webhook payload (body, headers, query). Useful for debugging; bloats LLM context.'
+ }
+ ],
+ default: 'text',
+ show: {
+ startInputType: 'webhookTrigger'
+ }
+ },
+ {
+ label: 'Custom Text',
+ name: 'webhookDefaultInput',
+ type: 'string',
+ rows: 3,
+ placeholder: 'Answer user question: {{ $webhook.body.question }}',
+ description:
+ 'Text passed to downstream nodes as the user input. Use {{ $webhook.body.* }}, {{ $webhook.headers.* }}, or {{ $webhook.query.* }} to interpolate fields from the incoming request.',
+ optional: true,
+ acceptVariable: true,
+ show: {
+ startInputType: 'webhookTrigger',
+ webhookInputMode: 'text'
+ }
+ },
+ {
+ label: 'Verify request signature',
+ name: 'webhookEnableAuth',
+ type: 'boolean',
+ description:
+ 'Reject incoming requests that do not include a valid signature. Turn this on if your sender (GitHub, Stripe, Slack, GitLab, etc.) signs each request with a shared secret, then generate a secret below and copy it to the sender. Leave off for testing or trusted networks.',
+ default: false,
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger'
+ }
+ },
+ {
+ label: 'Webhook Secret',
+ name: 'webhookSecret',
+ type: 'string',
+ description:
+ 'Click Generate Secret to create a random shared secret, then copy it into your sender so it can sign each request. Use Signature Header and Signature Type below to match how your sender delivers the signature.',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger',
+ webhookEnableAuth: true
+ }
+ },
+ {
+ label: 'Signature Header',
+ name: 'webhookSignatureHeader',
+ type: 'string',
+ description:
+ 'The request header that carries the signature. e.g. x-hub-signature-256 for GitHub, stripe-signature for Stripe, x-gitlab-token for GitLab.',
+ placeholder: 'x-webhook-signature',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger',
+ webhookEnableAuth: true
+ }
+ },
+ {
+ label: 'Signature Type',
+ name: 'webhookSignatureType',
+ type: 'options',
+ description:
+ 'How to verify the signature. HMAC-SHA256 for GitHub, Stripe, Slack (supports sha256= prefix automatically). Plain Token for GitLab-style plain secret comparison.',
+ options: [
+ { label: 'HMAC-SHA256', name: 'hmac-sha256' },
+ { label: 'Plain Token', name: 'plain-token' }
+ ],
+ default: 'hmac-sha256',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger',
+ webhookEnableAuth: true
+ }
+ },
+ {
+ label: 'Response Mode',
+ name: 'webhookResponseMode',
+ type: 'options',
+ description: 'How Flowise replies to the incoming webhook request.',
+ options: [
+ {
+ label: 'Synchronous',
+ name: 'sync',
+ description:
+ 'Wait for the flow to finish and return the full result as JSON. Simple but blocks the caller; can time out for senders with short HTTP windows.'
+ },
+ {
+ label: 'Asynchronous (callback)',
+ name: 'async',
+ description:
+ 'Return 202 Accepted immediately and run the flow in the background. Set a Callback URL below to have the result POSTed there when the flow finishes; leave it blank for fire-and-forget. Best for senders with short HTTP timeouts.'
+ },
+ {
+ label: 'Streaming (SSE)',
+ name: 'stream',
+ description:
+ 'Return a Server-Sent Events stream so the caller sees tokens and agent steps as they happen. Best for custom callers (browsers using fetch+ReadableStream, internal services). NOT compatible with senders that expect a single quick response.'
+ }
+ ],
+ default: 'sync',
+ show: {
+ startInputType: 'webhookTrigger'
+ }
+ },
+ {
+ label: 'Callback URL',
+ name: 'callbackUrl',
+ type: 'string',
+ description:
+ 'Optional. Flowise will POST the flow result to this URL when the flow finishes. Leave blank for fire-and-forget — the flow still runs in the background, but no callback is delivered.',
+ placeholder: 'https://example.com/flowise-callback',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger',
+ webhookResponseMode: 'async'
+ }
+ },
+ {
+ label: 'Callback Secret',
+ name: 'callbackSecret',
+ type: 'string',
+ description:
+ 'Optional. If set, outgoing callback POSTs are signed with HMAC-SHA256 and delivered as X-Flowise-Signature: sha256= so your callback endpoint can verify the request came from Flowise.',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger',
+ webhookResponseMode: 'async'
+ }
+ },
+ {
+ label: 'Validate request shape',
+ name: 'webhookEnableValidation',
+ type: 'boolean',
+ description:
+ 'Reject requests that are missing required headers, body fields, or query parameters declared below. Turn this on to enforce a request contract and catch bad requests early. Leave off to accept any payload and let the flow handle validation itself.',
+ default: false,
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger'
+ }
+ },
+ {
+ label: 'Expected Query Parameters',
+ name: 'webhookQueryParams',
+ description: 'Declare expected query parameters. Leave empty to accept any.',
+ type: 'array',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger',
+ webhookEnableValidation: true
+ },
+ array: [
+ {
+ label: 'Variable Name',
+ name: 'name',
+ type: 'string',
+ placeholder: 'e.g. page'
+ },
+ {
+ label: 'Required',
+ name: 'required',
+ type: 'boolean'
+ }
+ ]
+ },
+ {
+ label: 'Expected Body Parameters',
+ name: 'webhookBodyParams',
+ description: 'Define expected parameters in the webhook request body. Leave empty to accept any JSON body.',
+ type: 'array',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger',
+ webhookEnableValidation: true
+ },
+ array: [
+ {
+ label: 'Variable Name',
+ name: 'name',
+ type: 'string',
+ placeholder: 'e.g. userId'
+ },
+ {
+ label: 'Type',
+ name: 'type',
+ type: 'options',
+ options: [
+ {
+ label: 'String',
+ name: 'string'
+ },
+ {
+ label: 'Number',
+ name: 'number'
+ },
+ {
+ label: 'Boolean',
+ name: 'boolean'
+ },
+ {
+ label: 'Object',
+ name: 'object',
+ hide: { webhookContentType: 'application/x-www-form-urlencoded' }
+ },
+ {
+ label: 'Array[String]',
+ name: 'array[string]',
+ hide: { webhookContentType: 'application/x-www-form-urlencoded' }
+ },
+ {
+ label: 'Array[Number]',
+ name: 'array[number]',
+ hide: { webhookContentType: 'application/x-www-form-urlencoded' }
+ },
+ {
+ label: 'Array[Boolean]',
+ name: 'array[boolean]',
+ hide: { webhookContentType: 'application/x-www-form-urlencoded' }
+ },
+ {
+ label: 'Array[Object]',
+ name: 'array[object]',
+ hide: { webhookContentType: 'application/x-www-form-urlencoded' }
+ }
+ ],
+ default: 'string'
+ },
+ {
+ label: 'Required',
+ name: 'required',
+ type: 'boolean'
+ }
+ ]
+ },
+ {
+ label: 'Expected Headers',
+ name: 'webhookHeaderParams',
+ description: 'Declare expected request headers. Leave empty to accept any.',
+ type: 'array',
+ optional: true,
+ show: {
+ startInputType: 'webhookTrigger',
+ webhookEnableValidation: true
+ },
+ array: [
+ {
+ label: 'Header Name',
+ name: 'name',
+ type: 'string',
+ placeholder: 'e.g. x-github-event'
+ },
+ {
+ label: 'Required',
+ name: 'required',
+ type: 'boolean'
+ }
+ ]
+ },
{
label: 'Schedule Type',
name: 'scheduleType',
@@ -482,6 +812,40 @@ class Start_Agentflow implements INode {
outputData.form = form
}
+ if (startInputType === 'webhookTrigger') {
+ const webhookInputMode = (nodeData.inputs?.webhookInputMode as string) || 'text'
+
+ // Always preserve the webhook payload in inputData/outputData so downstream nodes can
+ // reference $webhook.* and human-input resume can restore the original trigger data.
+ // The runtime fallback is the authoritative source when set (text/none modes don't pass
+ // the payload through `input`); otherwise parse it back from the JSON string `input`.
+ let webhookPayload: any =
+ options.agentflowRuntime?.webhook && Object.keys(options.agentflowRuntime.webhook).length
+ ? options.agentflowRuntime.webhook
+ : input
+ if (typeof webhookPayload === 'string') {
+ try {
+ webhookPayload = JSON.parse(webhookPayload)
+ } catch (_) {
+ /* leave as string if not valid JSON */
+ }
+ }
+ inputData.webhook = webhookPayload
+ outputData.webhook = webhookPayload
+
+ if (webhookInputMode === 'none') {
+ // Single-space sentinel — same convention as scheduleInputMode='none'.
+ inputData.question = ' '
+ outputData.question = ' '
+ } else if (webhookInputMode === 'text') {
+ // executeAgentFlow pre-resolves $webhook.* refs and passes the result as `input`.
+ const resolved = (typeof input === 'string' && input) || ' '
+ inputData.question = resolved
+ outputData.question = resolved
+ }
+ // mode='payload' — webhook is exposed via outputData.webhook; no `question` is set.
+ }
+
if (startInputType === 'scheduleInput') {
const scheduleInputMode = (nodeData.inputs?.scheduleInputMode as string) || 'text'
if (scheduleInputMode === 'form') {
diff --git a/packages/components/src/Interface.ts b/packages/components/src/Interface.ts
index eb6af15c09e..a7442318b1a 100644
--- a/packages/components/src/Interface.ts
+++ b/packages/components/src/Interface.ts
@@ -64,6 +64,8 @@ export interface INodeOptionsValue {
description?: string
imageSrc?: string
client?: Array
+ show?: INodeDisplay
+ hide?: INodeDisplay
}
export interface INodeOutputsValue {
diff --git a/packages/components/src/headerValidation.test.ts b/packages/components/src/headerValidation.test.ts
index 137574b7f92..56d9d2b2e3c 100644
--- a/packages/components/src/headerValidation.test.ts
+++ b/packages/components/src/headerValidation.test.ts
@@ -1,4 +1,4 @@
-import { validateCustomHeaders } from './headerValidation'
+import { redactSensitiveHeaders, validateCustomHeaders } from './headerValidation'
describe('validateCustomHeaders', () => {
it('accepts a typical auth header set', () => {
@@ -64,3 +64,41 @@ describe('validateCustomHeaders', () => {
expect(() => validateCustomHeaders({ 'X-Foo': 123 as any })).toThrow(/must be a string/)
})
})
+
+describe('redactSensitiveHeaders', () => {
+ it('returns empty object when headers are undefined or null', () => {
+ expect(redactSensitiveHeaders(undefined)).toEqual({})
+ expect(redactSensitiveHeaders(null)).toEqual({})
+ })
+
+ it('redacts authorization header regardless of casing', () => {
+ const result = redactSensitiveHeaders({ Authorization: 'Bearer abc', AUTHORIZATION: 'token' })
+ expect(result.Authorization).toBe('[REDACTED]')
+ expect(result.AUTHORIZATION).toBe('[REDACTED]')
+ })
+
+ it('redacts the full set of credential-bearing headers', () => {
+ const result = redactSensitiveHeaders({
+ authorization: 'Bearer x',
+ 'proxy-authorization': 'Basic y',
+ cookie: 'session=z',
+ 'x-api-key': 'apikey',
+ 'x-auth-token': 'token',
+ 'x-amz-security-token': 'aws-token'
+ })
+ Object.values(result).forEach((v) => expect(v).toBe('[REDACTED]'))
+ })
+
+ it('passes non-sensitive headers through unchanged', () => {
+ const result = redactSensitiveHeaders({
+ 'content-type': 'application/json',
+ 'user-agent': 'GitHub-Hookshot/abc',
+ 'x-github-event': 'push',
+ authorization: 'Bearer leak'
+ })
+ expect(result['content-type']).toBe('application/json')
+ expect(result['user-agent']).toBe('GitHub-Hookshot/abc')
+ expect(result['x-github-event']).toBe('push')
+ expect(result.authorization).toBe('[REDACTED]')
+ })
+})
diff --git a/packages/components/src/headerValidation.ts b/packages/components/src/headerValidation.ts
index 7e1ec2b4e09..fd995b21b73 100644
--- a/packages/components/src/headerValidation.ts
+++ b/packages/components/src/headerValidation.ts
@@ -14,6 +14,18 @@ const DENIED_HEADER_NAMES = new Set([
const DENIED_HEADER_PREFIXES = ['proxy-', 'x-forwarded-', 'sec-']
+const SENSITIVE_HEADER_NAMES = new Set([
+ 'authorization',
+ 'proxy-authorization',
+ 'cookie',
+ 'set-cookie',
+ 'x-api-key',
+ 'x-auth-token',
+ 'x-amz-security-token'
+])
+
+const REDACTED_PLACEHOLDER = '[REDACTED]'
+
const MAX_HEADERS = 25
const MAX_KEY_LENGTH = 128
const MAX_VALUE_LENGTH = 2048
@@ -64,3 +76,17 @@ export function validateCustomHeaders(headers: Record): void {
}
}
}
+
+/**
+ * Returns a copy of `headers` with credential-bearing entries (Authorization, Cookie, X-Api-Key, …)
+ * replaced by a placeholder string. Used at trust boundaries before a header bag is exposed to flow
+ * templates, observers, or logs. Comparison is case-insensitive; non-sensitive headers pass through.
+ */
+export function redactSensitiveHeaders(headers: Record | undefined | null): Record {
+ if (!headers) return {}
+ const out: Record = {}
+ for (const [key, value] of Object.entries(headers)) {
+ out[key] = SENSITIVE_HEADER_NAMES.has(key.toLowerCase()) ? REDACTED_PLACEHOLDER : value
+ }
+ return out
+}
diff --git a/packages/server/src/Interface.ts b/packages/server/src/Interface.ts
index abb680f407f..b14a43b05dd 100644
--- a/packages/server/src/Interface.ts
+++ b/packages/server/src/Interface.ts
@@ -32,7 +32,8 @@ export enum ChatType {
EXTERNAL = 'EXTERNAL',
EVALUATION = 'EVALUATION',
MCP = 'MCP',
- SCHEDULED = 'SCHEDULED'
+ SCHEDULED = 'SCHEDULED',
+ WEBHOOK = 'WEBHOOK'
}
export enum ChatMessageRatingType {
@@ -74,6 +75,8 @@ export interface IChatFlow {
type?: ChatflowType
mcpServerConfig?: string
workspaceId: string
+ webhookSecret?: string | null
+ webhookSecretConfigured?: boolean
}
export interface IChatMessage {
@@ -371,6 +374,7 @@ export interface IncomingAgentflowInput extends Omit
question?: string
form?: Record
humanInput?: IHumanInput
+ webhook?: Record
}
export interface IActiveChatflows {
diff --git a/packages/server/src/controllers/chatflows/index.ts b/packages/server/src/controllers/chatflows/index.ts
index b88927b2224..dcc0c152d88 100644
--- a/packages/server/src/controllers/chatflows/index.ts
+++ b/packages/server/src/controllers/chatflows/index.ts
@@ -284,6 +284,44 @@ const checkIfChatflowHasChanged = async (req: Request, res: Response, next: Next
}
}
+const setWebhookSecret = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ if (!req.params.id) {
+ throw new InternalFlowiseError(
+ StatusCodes.PRECONDITION_FAILED,
+ `Error: chatflowsController.setWebhookSecret - id not provided!`
+ )
+ }
+ const workspaceId = req.user?.activeWorkspaceId
+ if (!workspaceId) {
+ throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Error: chatflowsController.setWebhookSecret - workspace not found!`)
+ }
+ const apiResponse = await chatflowsService.setWebhookSecret(req.params.id, workspaceId)
+ return res.json(apiResponse)
+ } catch (error) {
+ next(error)
+ }
+}
+
+const clearWebhookSecret = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ if (!req.params.id) {
+ throw new InternalFlowiseError(
+ StatusCodes.PRECONDITION_FAILED,
+ `Error: chatflowsController.clearWebhookSecret - id not provided!`
+ )
+ }
+ const workspaceId = req.user?.activeWorkspaceId
+ if (!workspaceId) {
+ throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Error: chatflowsController.clearWebhookSecret - workspace not found!`)
+ }
+ await chatflowsService.clearWebhookSecret(req.params.id, workspaceId)
+ return res.sendStatus(StatusCodes.NO_CONTENT)
+ } catch (error) {
+ next(error)
+ }
+}
+
const getScheduleStatus = async (req: Request, res: Response, next: NextFunction) => {
try {
if (!req.params?.id) {
@@ -396,6 +434,8 @@ export default {
getSinglePublicChatflow,
getSinglePublicChatbotConfig,
checkIfChatflowHasChanged,
+ setWebhookSecret,
+ clearWebhookSecret,
getScheduleStatus,
getScheduleTriggerLogs,
deleteScheduleTriggerLogs,
diff --git a/packages/server/src/controllers/webhook-listener/index.ts b/packages/server/src/controllers/webhook-listener/index.ts
new file mode 100644
index 00000000000..ca57815eb71
--- /dev/null
+++ b/packages/server/src/controllers/webhook-listener/index.ts
@@ -0,0 +1,114 @@
+import { Request, Response, NextFunction } from 'express'
+import { StatusCodes } from 'http-status-codes'
+import { InternalFlowiseError } from '../../errors/internalFlowiseError'
+import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
+import { getWebhookListenerRegistry } from '../../services/webhook-listener'
+import { IReactFlowObject } from '../../Interface'
+import chatflowsService from '../../services/chatflows'
+import logger from '../../utils/logger'
+
+const HEARTBEAT_MS = 30_000
+
+const assertChatflowIsWebhookTriggered = async (chatflowid: string, workspaceId?: string) => {
+ const chatflow = await chatflowsService.getChatflowById(chatflowid, workspaceId)
+ if (!chatflow) {
+ throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowid} not found`)
+ }
+ const parsedFlowData: IReactFlowObject = JSON.parse(chatflow.flowData)
+ const startNode = parsedFlowData.nodes.find((node) => node.data.name === 'startAgentflow')
+ if (startNode?.data?.inputs?.startInputType !== 'webhookTrigger') {
+ throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Chatflow ${chatflowid} is not configured as a webhook trigger`)
+ }
+}
+
+const registerListener = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const chatflowid = req.params.id
+ if (!chatflowid) throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'chatflow id is required')
+
+ await assertChatflowIsWebhookTriggered(chatflowid, req.user?.activeWorkspaceId)
+
+ const registry = getWebhookListenerRegistry()
+ const listenerId = await registry.register(chatflowid)
+ return res.json({ listenerId })
+ } catch (error) {
+ next(error)
+ }
+}
+
+const streamListener = async (req: Request, res: Response, next: NextFunction) => {
+ const chatflowid = req.params.id
+ const listenerId = req.params.listenerId
+
+ try {
+ if (!chatflowid || !listenerId) {
+ throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'chatflow id and listener id are required')
+ }
+
+ await assertChatflowIsWebhookTriggered(chatflowid, req.user?.activeWorkspaceId)
+
+ const sseStreamer = getRunningExpressApp().sseStreamer
+ const registry = getWebhookListenerRegistry()
+
+ res.setHeader('Content-Type', 'text/event-stream')
+ res.setHeader('Cache-Control', 'no-cache')
+ res.setHeader('Connection', 'keep-alive')
+ res.setHeader('X-Accel-Buffering', 'no')
+ res.flushHeaders()
+
+ sseStreamer.addClient(listenerId, res)
+
+ try {
+ await registry.heartbeat(chatflowid, listenerId)
+ } catch (err) {
+ logger.warn(`[webhookListener] Initial heartbeat failed for ${listenerId}: ${err}`)
+ }
+
+ // Initial "ready" beacon so the UI can flip from "connecting…" to "listening".
+ res.write(
+ 'message:\ndata:' +
+ JSON.stringify({ event: 'listenerReady', data: { listenerId, replicaId: registry.getReplicaId() } }) +
+ '\n\n'
+ )
+
+ // Heartbeat both keeps the SSE connection alive through proxies AND refreshes the
+ // registry TTL so the listener stays discoverable to incoming webhooks.
+ const heartbeat = setInterval(() => {
+ try {
+ res.write(':heartbeat\n\n')
+ registry.heartbeat(chatflowid, listenerId).catch(() => {})
+ } catch {
+ /* connection already torn down */
+ }
+ }, HEARTBEAT_MS)
+
+ req.on('close', async () => {
+ clearInterval(heartbeat)
+ sseStreamer.removeClient(listenerId)
+ try {
+ await registry.unregister(chatflowid, listenerId)
+ } catch (err) {
+ logger.warn(`[webhookListener] Failed to unregister ${listenerId}: ${err}`)
+ }
+ })
+ } catch (error) {
+ next(error)
+ }
+}
+
+const unregisterListener = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ const chatflowid = req.params.id
+ const listenerId = req.params.listenerId
+ if (!chatflowid || !listenerId) {
+ throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, 'chatflow id and listener id are required')
+ }
+ const registry = getWebhookListenerRegistry()
+ await registry.unregister(chatflowid, listenerId)
+ return res.json({ ok: true })
+ } catch (error) {
+ next(error)
+ }
+}
+
+export default { registerListener, streamListener, unregisterListener }
diff --git a/packages/server/src/controllers/webhook/index.test.ts b/packages/server/src/controllers/webhook/index.test.ts
new file mode 100644
index 00000000000..0874623642b
--- /dev/null
+++ b/packages/server/src/controllers/webhook/index.test.ts
@@ -0,0 +1,585 @@
+import { StatusCodes } from 'http-status-codes'
+import { Request, Response, NextFunction } from 'express'
+import { ChatType } from '../../Interface'
+
+const mockValidateWebhookChatflow = jest.fn()
+const mockBuildChatflow = jest.fn()
+const mockDispatchCallback = jest.fn()
+const mockCheckIfChatflowIsValidForStreaming = jest.fn()
+const mockSseStreamer = {
+ addExternalClient: jest.fn(),
+ streamMetadataEvent: jest.fn(),
+ streamErrorEvent: jest.fn(),
+ removeClient: jest.fn()
+}
+
+jest.mock('../../services/webhook', () => ({
+ __esModule: true,
+ default: { validateWebhookChatflow: mockValidateWebhookChatflow }
+}))
+jest.mock('../../services/predictions', () => ({
+ __esModule: true,
+ default: { buildChatflow: mockBuildChatflow }
+}))
+jest.mock('../../services/chatflows', () => ({
+ __esModule: true,
+ default: { checkIfChatflowIsValidForStreaming: mockCheckIfChatflowIsValidForStreaming }
+}))
+jest.mock('../../utils/rateLimit', () => ({
+ RateLimiterManager: {
+ getInstance: () => ({
+ getRateLimiter: () => (_req: Request, _res: Response, next: NextFunction) => next()
+ })
+ }
+}))
+jest.mock('../../utils/callbackDispatcher', () => ({
+ dispatchCallback: mockDispatchCallback
+}))
+jest.mock('../../utils/getRunningExpressApp', () => ({
+ getRunningExpressApp: () => ({ sseStreamer: mockSseStreamer })
+}))
+const mockBindExecution = jest.fn()
+jest.mock('../../services/webhook-listener', () => ({
+ getWebhookListenerRegistry: () => ({ bindExecution: mockBindExecution })
+}))
+jest.mock('uuid', () => ({ v4: () => 'generated-uuid' }))
+jest.mock('../../utils/logger', () => ({
+ __esModule: true,
+ default: { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }
+}))
+
+import webhookController from './index'
+
+const mockReq = (overrides: Partial = {}): Request =>
+ ({
+ params: { id: 'chatflow-123' },
+ body: { foo: 'bar' },
+ method: 'POST',
+ headers: { 'content-type': 'application/json' },
+ query: { page: '1' },
+ user: undefined,
+ ...overrides
+ } as unknown as Request)
+
+const mockRes = (): Response => {
+ const res = {} as Response
+ res.json = jest.fn().mockReturnValue(res)
+ res.status = jest.fn().mockReturnValue(res)
+ return res
+}
+
+const mockNext = (): NextFunction => jest.fn()
+
+describe('createWebhook', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ // Default: synchronous response mode
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'sync' as const })
+ })
+
+ it('calls next with PRECONDITION_FAILED when id is missing', async () => {
+ const req = mockReq({ params: {} as any })
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(next).toHaveBeenCalledWith(expect.objectContaining({ statusCode: StatusCodes.PRECONDITION_FAILED }))
+ })
+
+ it('calls next with error when validateWebhookChatflow rejects', async () => {
+ const error = { statusCode: StatusCodes.NOT_FOUND, message: 'not found' }
+ mockValidateWebhookChatflow.mockRejectedValue(error)
+
+ const req = mockReq()
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(next).toHaveBeenCalledWith(error)
+ })
+
+ it('wraps req.body under webhook key before calling buildChatflow', async () => {
+ mockBuildChatflow.mockResolvedValue({})
+
+ const originalBody = { foo: 'bar' }
+ const req = mockReq({ body: originalBody })
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(mockBuildChatflow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.objectContaining({
+ webhook: {
+ body: originalBody,
+ headers: expect.any(Object),
+ query: expect.any(Object)
+ },
+ // Controller pre-assigns a chatId so all response modes share an executionChatId
+ // and webhook-listener observers can be bound before the flow emits any events.
+ chatId: expect.any(String)
+ })
+ }),
+ ChatType.WEBHOOK
+ )
+ })
+
+ it('builds namespaced webhook payload with body, headers, and query', async () => {
+ mockBuildChatflow.mockResolvedValue({})
+
+ const req = mockReq({
+ body: { action: 'push' },
+ headers: { 'x-github-event': 'push' } as any,
+ query: { page: '2' } as any
+ })
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(mockBuildChatflow).toHaveBeenCalledWith(
+ expect.objectContaining({
+ body: expect.objectContaining({
+ webhook: {
+ body: { action: 'push' },
+ headers: expect.objectContaining({ 'x-github-event': 'push' }),
+ query: { page: '2' }
+ }
+ })
+ }),
+ ChatType.WEBHOOK
+ )
+ })
+
+ it('redacts credential-bearing headers before they reach the flow', async () => {
+ mockBuildChatflow.mockResolvedValue({})
+
+ const req = mockReq({
+ body: { action: 'push' },
+ headers: {
+ 'x-github-event': 'push',
+ authorization: 'Bearer leaked-token',
+ cookie: 'session=secret',
+ 'x-api-key': 'apikey'
+ } as any
+ })
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ const passedHeaders = (mockBuildChatflow.mock.calls[0][0] as any).body.webhook.headers
+ expect(passedHeaders.authorization).toBe('[REDACTED]')
+ expect(passedHeaders.cookie).toBe('[REDACTED]')
+ expect(passedHeaders['x-api-key']).toBe('[REDACTED]')
+ expect(passedHeaders['x-github-event']).toBe('push')
+ })
+
+ it('returns buildChatflow result as JSON response', async () => {
+ const apiResult = { output: 'ok' }
+ mockBuildChatflow.mockResolvedValue(apiResult)
+
+ const req = mockReq()
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(res.json).toHaveBeenCalledWith(apiResult)
+ expect(next).not.toHaveBeenCalled()
+ })
+
+ it('calls next with error when buildChatflow rejects', async () => {
+ const error = new Error('execution failed')
+ mockBuildChatflow.mockRejectedValue(error)
+
+ const req = mockReq()
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(next).toHaveBeenCalledWith(error)
+ })
+
+ it('passes the original body to validateWebhookChatflow before mutation', async () => {
+ mockBuildChatflow.mockResolvedValue({})
+
+ const req = mockReq({ body: { foo: 'bar' } })
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(mockValidateWebhookChatflow).toHaveBeenCalledWith(
+ 'chatflow-123',
+ undefined,
+ { foo: 'bar' },
+ 'POST',
+ expect.any(Object),
+ expect.any(Object),
+ 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)
+ })
+ }),
+ ChatType.WEBHOOK
+ )
+ })
+
+ // --- Async callback (FLOWISE-367) ---
+
+ it('returns 202 immediately when callbackUrl is configured on Start node', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' })
+ mockBuildChatflow.mockResolvedValue({ text: 'done' })
+ mockDispatchCallback.mockResolvedValue(undefined)
+ jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
+
+ const req = mockReq()
+ 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 () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' })
+ mockBuildChatflow.mockResolvedValue({ text: 'done' })
+ mockDispatchCallback.mockResolvedValue(undefined)
+ jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
+
+ const req = mockReq({
+ body: { chatId: 'existing-id', foo: 'bar' }
+ })
+ 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 () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' })
+ mockBuildChatflow.mockResolvedValue({ text: 'done' })
+ mockDispatchCallback.mockResolvedValue(undefined)
+ jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
+
+ const req = mockReq()
+ 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 () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' })
+ const apiResponse = { text: 'hello', executionId: 'exec-1' }
+ mockBuildChatflow.mockResolvedValue(apiResponse)
+ mockDispatchCallback.mockResolvedValue(undefined)
+ jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
+
+ const req = mockReq()
+ 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 () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' })
+ 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()
+ 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 () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'https://cb.example.com' })
+ mockBuildChatflow.mockRejectedValue(new Error('flow exploded'))
+ mockDispatchCallback.mockResolvedValue(undefined)
+ jest.spyOn(global, 'setImmediate').mockImplementation((fn: any) => fn())
+
+ const req = mockReq()
+ 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({
+ responseMode: 'async' as const,
+ callbackUrl: 'https://cb.example.com',
+ callbackSecret: 'node-secret'
+ })
+ 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(mockDispatchCallback).toHaveBeenCalledWith('https://cb.example.com', expect.any(Object), 'node-secret')
+ })
+
+ it('uses callbackUrl from Start node config when no header is present', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({
+ responseMode: 'async' as const,
+ 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('calls next with BAD_REQUEST when node callbackUrl is not a valid http/https URL', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const, callbackUrl: 'ftp://bad.example.com' })
+ const req = mockReq()
+ 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('returns synchronous JSON response when responseMode is sync', 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()
+ })
+
+ // --- Fire-and-forget mode (async on, no callback URL) ---
+
+ it('returns 202 immediately in fire-and-forget mode (async on, no callback URL)', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const })
+ mockBuildChatflow.mockResolvedValue({ text: 'done' })
+
+ const req = mockReq()
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(res.status).toHaveBeenCalledWith(202)
+ expect(res.json).toHaveBeenCalledWith(expect.objectContaining({ status: 'PROCESSING' }))
+ })
+
+ it('does not dispatch a callback in fire-and-forget mode even on success', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const })
+ mockBuildChatflow.mockResolvedValue({ text: 'done' })
+
+ const req = mockReq()
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+ // setImmediate hasn't fired yet — flush microtasks
+ await new Promise((r) => setImmediate(r))
+ await new Promise((r) => setImmediate(r))
+
+ expect(mockDispatchCallback).not.toHaveBeenCalled()
+ })
+
+ it('still runs the flow in fire-and-forget mode (does not skip buildChatflow)', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const })
+ mockBuildChatflow.mockResolvedValue({ text: 'done' })
+
+ const req = mockReq()
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+ await new Promise((r) => setImmediate(r))
+ await new Promise((r) => setImmediate(r))
+
+ expect(mockBuildChatflow).toHaveBeenCalled()
+ })
+
+ it('does not error on URL validation when async is on without a callback URL', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'async' as const })
+ mockBuildChatflow.mockResolvedValue({ text: 'done' })
+
+ const req = mockReq()
+ const res = mockRes()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(next).not.toHaveBeenCalled()
+ })
+
+ // --- Streaming response mode (SSE) ---
+
+ it('opens an SSE stream when responseMode is stream and chatflow is streamable', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'stream' as const })
+ mockCheckIfChatflowIsValidForStreaming.mockResolvedValue({ isStreaming: true })
+ mockBuildChatflow.mockResolvedValue({ chatId: 'generated-uuid', text: 'streamed' })
+
+ const req = mockReq()
+ const res = mockRes()
+ // flushHeaders/setHeader aren't on the default mockRes — stub them.
+ ;(res as any).setHeader = jest.fn()
+ ;(res as any).flushHeaders = jest.fn()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect((res as any).setHeader).toHaveBeenCalledWith('Content-Type', 'text/event-stream')
+ expect((res as any).flushHeaders).toHaveBeenCalled()
+ expect(mockSseStreamer.addExternalClient).toHaveBeenCalledWith('generated-uuid', res)
+ expect(mockBuildChatflow).toHaveBeenCalled()
+ expect(mockSseStreamer.streamMetadataEvent).toHaveBeenCalled()
+ expect(mockSseStreamer.removeClient).toHaveBeenCalledWith('generated-uuid')
+ })
+
+ it('sets streaming=true on req.body before invoking buildChatflow in stream mode', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'stream' as const })
+ mockCheckIfChatflowIsValidForStreaming.mockResolvedValue({ isStreaming: true })
+ mockBuildChatflow.mockResolvedValue({})
+
+ const req = mockReq()
+ const res = mockRes()
+ ;(res as any).setHeader = jest.fn()
+ ;(res as any).flushHeaders = jest.fn()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(mockBuildChatflow).toHaveBeenCalledWith(
+ expect.objectContaining({ body: expect.objectContaining({ streaming: true }) }),
+ ChatType.WEBHOOK
+ )
+ })
+
+ it('emits an error event and closes the stream when buildChatflow rejects', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'stream' as const })
+ mockCheckIfChatflowIsValidForStreaming.mockResolvedValue({ isStreaming: true })
+ mockBuildChatflow.mockRejectedValue(new Error('flow blew up'))
+
+ const req = mockReq()
+ const res = mockRes()
+ ;(res as any).setHeader = jest.fn()
+ ;(res as any).flushHeaders = jest.fn()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(mockSseStreamer.streamErrorEvent).toHaveBeenCalledWith('generated-uuid', expect.stringContaining('flow blew up'))
+ expect(mockSseStreamer.removeClient).toHaveBeenCalledWith('generated-uuid')
+ // next() must NOT be called — headers were already flushed; calling it would attempt a second response.
+ expect(next).not.toHaveBeenCalled()
+ })
+
+ it('falls back to synchronous JSON when responseMode is stream but chatflow is not streamable', async () => {
+ mockValidateWebhookChatflow.mockResolvedValue({ responseMode: 'stream' as const })
+ mockCheckIfChatflowIsValidForStreaming.mockResolvedValue({ isStreaming: false })
+ mockBuildChatflow.mockResolvedValue({ text: 'sync fallback' })
+
+ const req = mockReq()
+ const res = mockRes()
+ ;(res as any).setHeader = jest.fn()
+ ;(res as any).flushHeaders = jest.fn()
+ const next = mockNext()
+
+ await webhookController.createWebhook(req, res, next)
+
+ expect(mockSseStreamer.addExternalClient).not.toHaveBeenCalled()
+ expect(res.json).toHaveBeenCalledWith({ text: 'sync fallback' })
+ })
+})
diff --git a/packages/server/src/controllers/webhook/index.ts b/packages/server/src/controllers/webhook/index.ts
new file mode 100644
index 00000000000..9829cb3843d
--- /dev/null
+++ b/packages/server/src/controllers/webhook/index.ts
@@ -0,0 +1,178 @@
+import { Request, Response, NextFunction } from 'express'
+import { StatusCodes } from 'http-status-codes'
+import { v4 as uuidv4 } from 'uuid'
+import { RateLimiterManager } from '../../utils/rateLimit'
+import predictionsServices from '../../services/predictions'
+import chatflowsService from '../../services/chatflows'
+import webhookService from '../../services/webhook'
+import { getWebhookListenerRegistry } from '../../services/webhook-listener'
+import { redactSensitiveHeaders } from 'flowise-components'
+import { ChatType } from '../../Interface'
+import { InternalFlowiseError } from '../../errors/internalFlowiseError'
+import { dispatchCallback } from '../../utils/callbackDispatcher'
+import { getErrorMessage } from '../../errors/utils'
+import { getRunningExpressApp } from '../../utils/getRunningExpressApp'
+import logger from '../../utils/logger'
+
+const createWebhook = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ if (typeof req.params === 'undefined' || !req.params.id) {
+ throw new InternalFlowiseError(StatusCodes.PRECONDITION_FAILED, `Error: webhookController.createWebhook - id not provided!`)
+ }
+
+ const workspaceId = req.user?.activeWorkspaceId
+
+ // For form-encoded requests, unwrap JSON encoded in a `payload` field (e.g. GitHub webhooks)
+ // so $webhook.body.* resolves against the actual payload fields.
+ const contentType = (req.headers['content-type'] ?? '').toLowerCase()
+ let body = req.body
+ if (contentType.startsWith('application/x-www-form-urlencoded') && typeof body?.payload === 'string') {
+ try {
+ body = JSON.parse(body.payload)
+ } catch {
+ // leave body as-is if payload isn't valid JSON
+ }
+ }
+
+ const isResume = body?.humanInput != null
+
+ const { responseMode, callbackUrl, callbackSecret } = await webhookService.validateWebhookChatflow(
+ req.params.id,
+ workspaceId,
+ body,
+ req.method,
+ req.headers,
+ req.query,
+ (req as any).rawBody,
+ isResume ? { skipFieldValidation: true } : undefined
+ )
+
+ // Namespace the webhook payload so $webhook.body.*, $webhook.headers.*, $webhook.query.* can coexist
+ req.body = {
+ webhook: {
+ body,
+ headers: redactSensitiveHeaders(req.headers as Record),
+ query: req.query
+ }
+ }
+
+ const { humanInput, chatId: bodyChatId, sessionId } = body ?? {}
+ if (humanInput != null) req.body.humanInput = humanInput
+ if (bodyChatId != null) req.body.chatId = bodyChatId
+ if (sessionId != null) req.body.sessionId = sessionId
+
+ const executionChatId: string = (bodyChatId as string | undefined) ?? uuidv4()
+ req.body.chatId = executionChatId
+
+ // Mirror this execution's events to any UI panels currently listening to this flow.
+ try {
+ await getWebhookListenerRegistry().bindExecution(req.params.id, executionChatId)
+ } catch (err) {
+ logger.warn(`[webhookController] Failed to bind webhook listeners: ${getErrorMessage(err)}`)
+ }
+
+ if (responseMode === 'stream') {
+ // Streaming mode: open an SSE channel and let downstream nodes push events through sseStreamer
+ // Falls back to synchronous JSON if the chatflow has no streaming-capable end nodes
+ const streamable = await chatflowsService.checkIfChatflowIsValidForStreaming(req.params.id)
+ if (streamable?.isStreaming) {
+ const sseStreamer = getRunningExpressApp().sseStreamer
+ const chatId = executionChatId
+ req.body.streaming = true
+
+ res.setHeader('Content-Type', 'text/event-stream')
+ res.setHeader('Cache-Control', 'no-cache')
+ res.setHeader('Connection', 'keep-alive')
+ res.setHeader('X-Accel-Buffering', 'no')
+ res.flushHeaders()
+ sseStreamer.addExternalClient(chatId, res)
+
+ try {
+ const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK)
+ sseStreamer.streamMetadataEvent(chatId, apiResponse)
+ } catch (err: any) {
+ sseStreamer.streamErrorEvent(chatId, getErrorMessage(err))
+ } finally {
+ sseStreamer.removeClient(chatId)
+ }
+ return
+ }
+ }
+
+ if (responseMode === 'async') {
+ // Validate the callback URL only when one was provided. Without a URL, the flow runs
+ // fire-and-forget — the 202 still goes out, but no callback is delivered when it finishes.
+ if (callbackUrl) {
+ try {
+ const parsed = new URL(callbackUrl)
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') throw new Error()
+ } catch {
+ throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid callbackUrl: must be a valid http or https URL`)
+ }
+ }
+
+ // 202 response and the background execution share the pre-assigned executionChatId
+ const chatId = executionChatId
+
+ res.status(202).json({ chatId, status: 'PROCESSING' })
+
+ setImmediate(async () => {
+ try {
+ const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK)
+
+ if (!callbackUrl) {
+ getRunningExpressApp().sseStreamer.removeClient(chatId)
+ return // fire-and-forget — no delivery
+ }
+
+ // apiResponse.action is the parsed humanInputAction — only present when flow is STOPPED (FLOWISE-387)
+ if (apiResponse.action) {
+ await dispatchCallback(
+ callbackUrl,
+ {
+ status: 'STOPPED',
+ chatId,
+ data: { text: apiResponse.text, executionId: apiResponse.executionId, action: apiResponse.action }
+ },
+ callbackSecret
+ )
+ } else {
+ await dispatchCallback(callbackUrl, { status: 'SUCCESS', chatId, data: apiResponse }, callbackSecret)
+ }
+ } catch (err: any) {
+ if (callbackUrl) {
+ await dispatchCallback(callbackUrl, { status: 'ERROR', chatId, error: getErrorMessage(err) }, callbackSecret)
+ } else {
+ logger.error(`[webhookController] fire-and-forget execution failed for chatId=${chatId}: ${getErrorMessage(err)}`)
+ }
+ } finally {
+ // Notify webhook listeners that this execution is done; their SSE connections stay open.
+ getRunningExpressApp().sseStreamer.removeClient(chatId)
+ }
+ })
+ return
+ }
+
+ try {
+ const apiResponse = await predictionsServices.buildChatflow(req, ChatType.WEBHOOK)
+ return res.json(apiResponse)
+ } finally {
+ getRunningExpressApp().sseStreamer.removeClient(executionChatId)
+ }
+ } catch (error) {
+ next(error)
+ }
+}
+
+const getRateLimiterMiddleware = async (req: Request, res: Response, next: NextFunction) => {
+ try {
+ return RateLimiterManager.getInstance().getRateLimiter()(req, res, next)
+ } catch (error) {
+ next(error)
+ }
+}
+
+export default {
+ createWebhook,
+ getRateLimiterMiddleware
+}
diff --git a/packages/server/src/database/entities/ChatFlow.ts b/packages/server/src/database/entities/ChatFlow.ts
index bc1a2347ec4..bd895df1dc1 100644
--- a/packages/server/src/database/entities/ChatFlow.ts
+++ b/packages/server/src/database/entities/ChatFlow.ts
@@ -64,6 +64,12 @@ export class ChatFlow implements IChatFlow {
@Column({ nullable: true, type: 'text' })
mcpServerConfig?: string
+ @Column({ nullable: true, type: 'text', select: false })
+ webhookSecret?: string | null
+
+ @Column({ nullable: true, default: false })
+ webhookSecretConfigured?: boolean
+
@Column({ nullable: false, type: 'text' })
workspaceId: string
}
diff --git a/packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts
new file mode 100644
index 00000000000..df14de0d812
--- /dev/null
+++ b/packages/server/src/database/migrations/mariadb/1776240000003-AddWebhookSecretToChatFlow.ts
@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddWebhookSecretToChatFlow1776240000003 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecret` TEXT;')
+ await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecretConfigured` BOOLEAN DEFAULT FALSE;')
+ await queryRunner.query('UPDATE `chat_flow` SET `webhookSecretConfigured` = TRUE WHERE `webhookSecret` IS NOT NULL;')
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecretConfigured`;')
+ await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecret`;')
+ }
+}
diff --git a/packages/server/src/database/migrations/mariadb/index.ts b/packages/server/src/database/migrations/mariadb/index.ts
index 3f66de53f19..82ed7dc488f 100644
--- a/packages/server/src/database/migrations/mariadb/index.ts
+++ b/packages/server/src/database/migrations/mariadb/index.ts
@@ -44,6 +44,7 @@ import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
+import { AddWebhookSecretToChatFlow1776240000003 } from './1776240000003-AddWebhookSecretToChatFlow'
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities'
@@ -117,6 +118,7 @@ export const mariadbMigrations = [
AddApiKeyPermission1765360298674,
AddReasonContentToChatMessage1764759496768,
AddCustomMcpServer1766000000000,
+ AddWebhookSecretToChatFlow1776240000003,
AddMcpServerConfigToChatFlow1767000000000,
AddScheduleEntities1772000000000
]
diff --git a/packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts
new file mode 100644
index 00000000000..23753a3f53c
--- /dev/null
+++ b/packages/server/src/database/migrations/mysql/1776240000002-AddWebhookSecretToChatFlow.ts
@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddWebhookSecretToChatFlow1776240000002 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecret` TEXT;')
+ await queryRunner.query('ALTER TABLE `chat_flow` ADD COLUMN `webhookSecretConfigured` BOOLEAN DEFAULT FALSE;')
+ await queryRunner.query('UPDATE `chat_flow` SET `webhookSecretConfigured` = TRUE WHERE `webhookSecret` IS NOT NULL;')
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecretConfigured`;')
+ await queryRunner.query('ALTER TABLE `chat_flow` DROP COLUMN `webhookSecret`;')
+ }
+}
diff --git a/packages/server/src/database/migrations/mysql/index.ts b/packages/server/src/database/migrations/mysql/index.ts
index 00505cd321d..58c08d4c7b5 100644
--- a/packages/server/src/database/migrations/mysql/index.ts
+++ b/packages/server/src/database/migrations/mysql/index.ts
@@ -44,6 +44,7 @@ import { AddChatFlowNameIndex1759424828558 } from './1759424828558-AddChatFlowNa
import { FixDocumentStoreFileChunkLongText1765000000000 } from './1765000000000-FixDocumentStoreFileChunkLongText'
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
+import { AddWebhookSecretToChatFlow1776240000002 } from './1776240000002-AddWebhookSecretToChatFlow'
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities'
@@ -118,6 +119,7 @@ export const mysqlMigrations = [
FixDocumentStoreFileChunkLongText1765000000000,
AddApiKeyPermission1765360298674,
AddReasonContentToChatMessage1764759496768,
+ AddWebhookSecretToChatFlow1776240000002,
AddCustomMcpServer1766000000000,
AddMcpServerConfigToChatFlow1767000000000,
AddScheduleEntities1772000000000
diff --git a/packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts
new file mode 100644
index 00000000000..787f51a9ec2
--- /dev/null
+++ b/packages/server/src/database/migrations/postgres/1776240000001-AddWebhookSecretToChatFlow.ts
@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddWebhookSecretToChatFlow1776240000001 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN IF NOT EXISTS "webhookSecret" TEXT;`)
+ await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN IF NOT EXISTS "webhookSecretConfigured" BOOLEAN DEFAULT FALSE;`)
+ await queryRunner.query(`UPDATE "chat_flow" SET "webhookSecretConfigured" = TRUE WHERE "webhookSecret" IS NOT NULL;`)
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecretConfigured";`)
+ await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecret";`)
+ }
+}
diff --git a/packages/server/src/database/migrations/postgres/index.ts b/packages/server/src/database/migrations/postgres/index.ts
index ad23c95c9bb..750d7daf647 100644
--- a/packages/server/src/database/migrations/postgres/index.ts
+++ b/packages/server/src/database/migrations/postgres/index.ts
@@ -42,6 +42,7 @@ import { AddTextToSpeechToChatFlow1759419194331 } from './1759419194331-AddTextT
import { AddChatFlowNameIndex1759424903973 } from './1759424903973-AddChatFlowNameIndex'
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
+import { AddWebhookSecretToChatFlow1776240000001 } from './1776240000001-AddWebhookSecretToChatFlow'
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities'
@@ -114,6 +115,7 @@ export const postgresMigrations = [
AddChatFlowNameIndex1759424903973,
AddApiKeyPermission1765360298674,
AddReasonContentToChatMessage1764759496768,
+ AddWebhookSecretToChatFlow1776240000001,
AddCustomMcpServer1766000000000,
AddMcpServerConfigToChatFlow1767000000000,
AddScheduleEntities1772000000000
diff --git a/packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts b/packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts
new file mode 100644
index 00000000000..e8217f14c4f
--- /dev/null
+++ b/packages/server/src/database/migrations/sqlite/1776240000000-AddWebhookSecretToChatFlow.ts
@@ -0,0 +1,14 @@
+import { MigrationInterface, QueryRunner } from 'typeorm'
+
+export class AddWebhookSecretToChatFlow1776240000000 implements MigrationInterface {
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN "webhookSecret" TEXT;`)
+ await queryRunner.query(`ALTER TABLE "chat_flow" ADD COLUMN "webhookSecretConfigured" BOOLEAN DEFAULT FALSE;`)
+ await queryRunner.query(`UPDATE "chat_flow" SET "webhookSecretConfigured" = TRUE WHERE "webhookSecret" IS NOT NULL;`)
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecretConfigured";`)
+ await queryRunner.query(`ALTER TABLE "chat_flow" DROP COLUMN "webhookSecret";`)
+ }
+}
diff --git a/packages/server/src/database/migrations/sqlite/index.ts b/packages/server/src/database/migrations/sqlite/index.ts
index f4378260178..db277bacda0 100644
--- a/packages/server/src/database/migrations/sqlite/index.ts
+++ b/packages/server/src/database/migrations/sqlite/index.ts
@@ -40,6 +40,7 @@ import { AddTextToSpeechToChatFlow1759419136055 } from './1759419136055-AddTextT
import { AddChatFlowNameIndex1759424923093 } from './1759424923093-AddChatFlowNameIndex'
import { AddApiKeyPermission1765360298674 } from './1765360298674-AddApiKeyPermission'
import { AddReasonContentToChatMessage1764759496768 } from './1764759496768-AddReasonContentToChatMessage'
+import { AddWebhookSecretToChatFlow1776240000000 } from './1776240000000-AddWebhookSecretToChatFlow'
import { AddCustomMcpServer1766000000000 } from './1766000000000-AddCustomMcpServer'
import { AddMcpServerConfigToChatFlow1767000000000 } from './1767000000000-AddMcpServerConfigToChatFlow'
import { AddScheduleEntities1772000000000 } from './1772000000000-AddScheduleEntities'
@@ -110,6 +111,7 @@ export const sqliteMigrations = [
AddChatFlowNameIndex1759424923093,
AddApiKeyPermission1765360298674,
AddReasonContentToChatMessage1764759496768,
+ AddWebhookSecretToChatFlow1776240000000,
AddCustomMcpServer1766000000000,
AddMcpServerConfigToChatFlow1767000000000,
AddScheduleEntities1772000000000
diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts
index ee1d6f6dc5b..d260b827188 100644
--- a/packages/server/src/index.ts
+++ b/packages/server/src/index.ts
@@ -25,6 +25,7 @@ import { NodesPool } from './NodesPool'
import { QueueManager } from './queue/QueueManager'
import { ScheduleBeat } from './schedule/ScheduleBeat'
import { RedisEventSubscriber } from './queue/RedisEventSubscriber'
+import { initWebhookListenerRegistry } from './services/webhook-listener'
import flowiseApiV1Router from './routes'
import { UsageCacheManager } from './UsageCacheManager'
import { getEncryptionKey, getNodeModulesPackagePath } from './utils'
@@ -155,6 +156,9 @@ export class App {
logger.info('🔗 [server]: Redis event subscriber connected successfully')
}
+ await initWebhookListenerRegistry(this.sseStreamer, this.redisSubscriber)
+ logger.info('📡 [server]: Webhook listener registry initialized successfully')
+
// Init ScheduleBeat (works in both queue and non-queue mode)
await ScheduleBeat.getInstance().init()
logger.info('⏰ [server]: ScheduleBeat initialized successfully')
@@ -168,8 +172,13 @@ export class App {
async config() {
// Limit is needed to allow sending/receiving base64 encoded string
const flowise_file_size_limit = process.env.FLOWISE_FILE_SIZE_LIMIT || '50mb'
- this.app.use(express.json({ limit: flowise_file_size_limit }))
- this.app.use(express.urlencoded({ limit: flowise_file_size_limit, extended: true }))
+
+ // Preserve raw bytes before JSON parsing for webhook HMAC signature verification
+ const captureRawBody = (req: Request, _res: Response, buf: Buffer) => {
+ ;(req as any).rawBody = buf
+ }
+ this.app.use(express.json({ limit: flowise_file_size_limit, verify: captureRawBody }))
+ this.app.use(express.urlencoded({ limit: flowise_file_size_limit, extended: true, verify: captureRawBody }))
// Enhanced trust proxy settings for load balancer
let trustProxy: string | boolean | number | undefined = process.env.TRUST_PROXY
diff --git a/packages/server/src/queue/RedisEventPublisher.ts b/packages/server/src/queue/RedisEventPublisher.ts
index 2c839072ae3..88729e2ed65 100644
--- a/packages/server/src/queue/RedisEventPublisher.ts
+++ b/packages/server/src/queue/RedisEventPublisher.ts
@@ -1,49 +1,14 @@
import { IServerSideEventStreamer } from 'flowise-components'
import { createClient } from 'redis'
import logger from '../utils/logger'
+import { createRedisClient } from '../utils/redis'
export class RedisEventPublisher implements IServerSideEventStreamer {
private redisPublisher: ReturnType
private connectPromise: Promise | null = null
constructor() {
- if (process.env.REDIS_URL) {
- this.redisPublisher = createClient({
- url: process.env.REDIS_URL,
- socket: {
- keepAlive:
- process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10))
- ? parseInt(process.env.REDIS_KEEP_ALIVE, 10)
- : undefined
- },
- pingInterval:
- process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10))
- ? parseInt(process.env.REDIS_KEEP_ALIVE, 10)
- : undefined
- })
- } else {
- this.redisPublisher = createClient({
- username: process.env.REDIS_USERNAME || undefined,
- password: process.env.REDIS_PASSWORD || undefined,
- socket: {
- host: process.env.REDIS_HOST || 'localhost',
- port: parseInt(process.env.REDIS_PORT || '6379'),
- tls: process.env.REDIS_TLS === 'true',
- cert: process.env.REDIS_CERT ? Buffer.from(process.env.REDIS_CERT, 'base64') : undefined,
- key: process.env.REDIS_KEY ? Buffer.from(process.env.REDIS_KEY, 'base64') : undefined,
- ca: process.env.REDIS_CA ? Buffer.from(process.env.REDIS_CA, 'base64') : undefined,
- keepAlive:
- process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10))
- ? parseInt(process.env.REDIS_KEEP_ALIVE, 10)
- : undefined
- },
- pingInterval:
- process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10))
- ? parseInt(process.env.REDIS_KEEP_ALIVE, 10)
- : undefined
- })
- }
-
+ this.redisPublisher = createRedisClient()
this.setupEventListeners()
}
diff --git a/packages/server/src/queue/RedisEventSubscriber.ts b/packages/server/src/queue/RedisEventSubscriber.ts
index f892c1dc340..d734794bb0e 100644
--- a/packages/server/src/queue/RedisEventSubscriber.ts
+++ b/packages/server/src/queue/RedisEventSubscriber.ts
@@ -1,6 +1,7 @@
import { createClient } from 'redis'
import { SSEStreamer } from '../utils/SSEStreamer'
import logger from '../utils/logger'
+import { createRedisClient } from '../utils/redis'
export class RedisEventSubscriber {
private redisSubscriber: ReturnType
@@ -9,42 +10,7 @@ export class RedisEventSubscriber {
private cleanupInterval: NodeJS.Timeout | null = null
constructor(sseStreamer: SSEStreamer) {
- if (process.env.REDIS_URL) {
- this.redisSubscriber = createClient({
- url: process.env.REDIS_URL,
- socket: {
- keepAlive:
- process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10))
- ? parseInt(process.env.REDIS_KEEP_ALIVE, 10)
- : undefined
- },
- pingInterval:
- process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10))
- ? parseInt(process.env.REDIS_KEEP_ALIVE, 10)
- : undefined
- })
- } else {
- this.redisSubscriber = createClient({
- username: process.env.REDIS_USERNAME || undefined,
- password: process.env.REDIS_PASSWORD || undefined,
- socket: {
- host: process.env.REDIS_HOST || 'localhost',
- port: parseInt(process.env.REDIS_PORT || '6379'),
- tls: process.env.REDIS_TLS === 'true',
- cert: process.env.REDIS_CERT ? Buffer.from(process.env.REDIS_CERT, 'base64') : undefined,
- key: process.env.REDIS_KEY ? Buffer.from(process.env.REDIS_KEY, 'base64') : undefined,
- ca: process.env.REDIS_CA ? Buffer.from(process.env.REDIS_CA, 'base64') : undefined,
- keepAlive:
- process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10))
- ? parseInt(process.env.REDIS_KEEP_ALIVE, 10)
- : undefined
- },
- pingInterval:
- process.env.REDIS_KEEP_ALIVE && !isNaN(parseInt(process.env.REDIS_KEEP_ALIVE, 10))
- ? parseInt(process.env.REDIS_KEEP_ALIVE, 10)
- : undefined
- })
- }
+ this.redisSubscriber = createRedisClient()
this.sseStreamer = sseStreamer
this.setupEventListeners()
@@ -124,7 +90,7 @@ export class RedisEventSubscriber {
startPeriodicCleanup(intervalMs: number = 60_000) {
this.cleanupInterval = setInterval(() => {
- const staleChannels = Array.from(this.subscribedChannels).filter((channel) => !this.sseStreamer.hasClient(channel))
+ const staleChannels = Array.from(this.subscribedChannels).filter((channel) => !this.sseStreamer.hasClientOrObserver(channel))
if (staleChannels.length > 0) {
for (const channel of staleChannels) {
this.unsubscribe(channel)
diff --git a/packages/server/src/routes/chatflows/index.ts b/packages/server/src/routes/chatflows/index.ts
index 22eaac4bfba..8e963b041a7 100644
--- a/packages/server/src/routes/chatflows/index.ts
+++ b/packages/server/src/routes/chatflows/index.ts
@@ -33,6 +33,10 @@ router.put(
// DELETE
router.delete(['/', '/:id'], checkAnyPermission('chatflows:delete,agentflows:delete'), chatflowsController.deleteChatflow)
+// WEBHOOK SECRET
+router.post('/:id/webhook-secret', checkAnyPermission('chatflows:update,agentflows:update'), chatflowsController.setWebhookSecret)
+router.delete('/:id/webhook-secret', checkAnyPermission('chatflows:update,agentflows:update'), chatflowsController.clearWebhookSecret)
+
// CHECK FOR CHANGE
router.get(
'/has-changed/:id/:lastUpdatedDateTime',
diff --git a/packages/server/src/routes/index.ts b/packages/server/src/routes/index.ts
index b7196f3532e..86f3b0d11ac 100644
--- a/packages/server/src/routes/index.ts
+++ b/packages/server/src/routes/index.ts
@@ -49,6 +49,8 @@ import upsertHistoryRouter from './upsert-history'
import variablesRouter from './variables'
import vectorRouter from './vectors'
import verifyRouter from './verify'
+import webhookRouter from './webhook'
+import webhookListenerRouter from './webhook-listener'
import versionRouter from './versions'
import pricingRouter from './pricing'
import nvidiaNimRouter from './nvidia-nim'
@@ -118,6 +120,8 @@ router.use('/tools', toolsRouter)
router.use('/variables', variablesRouter)
router.use('/vector', vectorRouter)
router.use('/verify', verifyRouter)
+router.use('/webhook', webhookRouter)
+router.use('/webhook-listener', webhookListenerRouter)
router.use('/version', versionRouter)
router.use('/upsert-history', upsertHistoryRouter)
router.use('/settings', settingsRouter)
diff --git a/packages/server/src/routes/webhook-listener/index.ts b/packages/server/src/routes/webhook-listener/index.ts
new file mode 100644
index 00000000000..49986a907e2
--- /dev/null
+++ b/packages/server/src/routes/webhook-listener/index.ts
@@ -0,0 +1,13 @@
+import express from 'express'
+import webhookListenerController from '../../controllers/webhook-listener'
+import { checkAnyPermission } from '../../enterprise/rbac/PermissionCheck'
+
+const router = express.Router()
+
+const requireFlowEdit = checkAnyPermission('chatflows:create,chatflows:update,agentflows:create,agentflows:update')
+
+router.post('/:id/register', requireFlowEdit, webhookListenerController.registerListener)
+router.get('/:id/stream/:listenerId', requireFlowEdit, webhookListenerController.streamListener)
+router.delete('/:id/listener/:listenerId', requireFlowEdit, webhookListenerController.unregisterListener)
+
+export default router
diff --git a/packages/server/src/routes/webhook/index.ts b/packages/server/src/routes/webhook/index.ts
new file mode 100644
index 00000000000..029bdd51299
--- /dev/null
+++ b/packages/server/src/routes/webhook/index.ts
@@ -0,0 +1,9 @@
+import express from 'express'
+import webhookController from '../../controllers/webhook'
+
+const router = express.Router()
+
+// Unauthenticated at route level — API key validation happens downstream in utilBuildChatflow.
+router.all('/:id', webhookController.getRateLimiterMiddleware, webhookController.createWebhook)
+
+export default router
diff --git a/packages/server/src/services/chatflows/index.ts b/packages/server/src/services/chatflows/index.ts
index 531d838e54e..b786fe9bd8a 100644
--- a/packages/server/src/services/chatflows/index.ts
+++ b/packages/server/src/services/chatflows/index.ts
@@ -1,3 +1,4 @@
+import { randomBytes } from 'crypto'
import { ICommonObject, removeFolderFromStorage } from 'flowise-components'
import { StatusCodes } from 'http-status-codes'
import { Brackets, In, QueryRunner } from 'typeorm'
@@ -14,7 +15,15 @@ import { getWorkspaceSearchOptions } from '../../enterprise/utils/ControllerServ
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import { getErrorMessage } from '../../errors/utils'
import documentStoreService from '../../services/documentstore'
-import { constructGraphs, getAppVersion, getEndingNodes, getTelemetryFlowObj, isFlowValidForStream } from '../../utils'
+import {
+ constructGraphs,
+ decryptCredentialData,
+ encryptCredentialData,
+ getAppVersion,
+ getEndingNodes,
+ getTelemetryFlowObj,
+ isFlowValidForStream
+} from '../../utils'
import { sanitizeAllowedUploadMimeTypesFromConfig } from '../../utils/fileValidation'
import { containsBase64File, updateFlowDataWithFilePaths } from '../../utils/fileRepository'
import { sanitizeFlowDataForPublicEndpoint } from '../../utils/sanitizeFlowData'
@@ -616,6 +625,66 @@ const checkIfChatflowHasChanged = async (chatflowId: string, lastUpdatedDateTime
}
}
+const setWebhookSecret = async (chatflowId: string, workspaceId: string): Promise<{ webhookSecret: string }> => {
+ try {
+ const appServer = getRunningExpressApp()
+ const repo = appServer.AppDataSource.getRepository(ChatFlow)
+ const chatflow = await repo.findOne({ where: { id: chatflowId, workspaceId } })
+ if (!chatflow) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`)
+ const plaintext = randomBytes(32).toString('hex')
+ chatflow.webhookSecret = await encryptCredentialData({ secret: plaintext })
+ chatflow.webhookSecretConfigured = true
+ await repo.save(chatflow)
+ return { webhookSecret: plaintext }
+ } catch (error) {
+ if (error instanceof InternalFlowiseError) throw error
+ throw new InternalFlowiseError(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Error: chatflowsService.setWebhookSecret - ${getErrorMessage(error)}`
+ )
+ }
+}
+
+const clearWebhookSecret = async (chatflowId: string, workspaceId: string): Promise => {
+ try {
+ const appServer = getRunningExpressApp()
+ const repo = appServer.AppDataSource.getRepository(ChatFlow)
+ const chatflow = await repo.findOne({ where: { id: chatflowId, workspaceId } })
+ if (!chatflow) throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`)
+ chatflow.webhookSecret = null
+ chatflow.webhookSecretConfigured = false
+ await repo.save(chatflow)
+ } catch (error) {
+ if (error instanceof InternalFlowiseError) throw error
+ throw new InternalFlowiseError(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Error: chatflowsService.clearWebhookSecret - ${getErrorMessage(error)}`
+ )
+ }
+}
+
+const getWebhookSecret = async (chatflowId: string, workspaceId: string): Promise => {
+ try {
+ const appServer = getRunningExpressApp()
+ const dbResponse = await appServer.AppDataSource.getRepository(ChatFlow)
+ .createQueryBuilder('chatflow')
+ .select('chatflow.webhookSecret')
+ .where('chatflow.id = :id', { id: chatflowId })
+ .andWhere('chatflow.workspaceId = :workspaceId', { workspaceId })
+ .getOne()
+ const stored = dbResponse?.webhookSecret
+ if (!stored) return null
+ const decrypted = await decryptCredentialData(stored)
+ return (decrypted?.secret as string | undefined) ?? null
+ } catch (error) {
+ if (error instanceof InternalFlowiseError) throw error
+ throw new InternalFlowiseError(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Error: chatflowsService.getWebhookSecret - ${getErrorMessage(error)}`
+ )
+ }
+}
+
export default {
assertChatflowIdsInWorkspace,
checkIfChatflowIsValidForStreaming,
@@ -630,5 +699,8 @@ export default {
updateChatflow,
getSinglePublicChatbotConfig,
checkIfChatflowHasChanged,
- getAllChatflowsCountByOrganization
+ getAllChatflowsCountByOrganization,
+ setWebhookSecret,
+ clearWebhookSecret,
+ getWebhookSecret
}
diff --git a/packages/server/src/services/export-import/index.ts b/packages/server/src/services/export-import/index.ts
index 5c4e4cc43e1..0a0a60f1685 100644
--- a/packages/server/src/services/export-import/index.ts
+++ b/packages/server/src/services/export-import/index.ts
@@ -953,6 +953,8 @@ const getChatType = (chatType?: ChatType): string => {
return 'MCP'
case ChatType.SCHEDULED:
return 'Scheduled'
+ case ChatType.WEBHOOK:
+ return 'Webhook'
}
}
diff --git a/packages/server/src/services/predictions/index.ts b/packages/server/src/services/predictions/index.ts
index 5d1d71ec098..0dc7f225ea2 100644
--- a/packages/server/src/services/predictions/index.ts
+++ b/packages/server/src/services/predictions/index.ts
@@ -1,12 +1,13 @@
import { Request } from 'express'
import { StatusCodes } from 'http-status-codes'
import { utilBuildChatflow } from '../../utils/buildChatflow'
+import { ChatType } from '../../Interface'
import { InternalFlowiseError } from '../../errors/internalFlowiseError'
import { getErrorMessage } from '../../errors/utils'
-const buildChatflow = async (req: Request) => {
+const buildChatflow = async (req: Request, chatType?: ChatType) => {
try {
- const dbResponse = await utilBuildChatflow(req)
+ const dbResponse = await utilBuildChatflow(req, false, chatType)
return dbResponse
} catch (error) {
throw new InternalFlowiseError(
diff --git a/packages/server/src/services/webhook-listener/index.ts b/packages/server/src/services/webhook-listener/index.ts
new file mode 100644
index 00000000000..f881ba26a2a
--- /dev/null
+++ b/packages/server/src/services/webhook-listener/index.ts
@@ -0,0 +1,2 @@
+export { getWebhookListenerRegistry, initWebhookListenerRegistry } from './registry'
+export type { IWebhookListenerRegistry, WebhookListenerEntry } from './registry'
diff --git a/packages/server/src/services/webhook-listener/registry.ts b/packages/server/src/services/webhook-listener/registry.ts
new file mode 100644
index 00000000000..29d4b7da668
--- /dev/null
+++ b/packages/server/src/services/webhook-listener/registry.ts
@@ -0,0 +1,232 @@
+import { createClient } from 'redis'
+import { v4 as uuidv4 } from 'uuid'
+import { MODE } from '../../Interface'
+import logger from '../../utils/logger'
+import { createRedisClient } from '../../utils/redis'
+import type { SSEStreamer } from '../../utils/SSEStreamer'
+import type { RedisEventSubscriber } from '../../queue/RedisEventSubscriber'
+
+export type WebhookListenerEntry = { listenerId: string; replicaId: string }
+
+export interface IWebhookListenerRegistry {
+ /** Register a new listener for a flow. Returns the generated listenerId. */
+ register(chatflowid: string): Promise
+
+ /** Refresh TTL on the listener so it stays alive past the inactivity window. */
+ heartbeat(chatflowid: string, listenerId: string): Promise
+
+ /** Drop a listener immediately (called on SSE disconnect). */
+ unregister(chatflowid: string, listenerId: string): Promise
+
+ /** Look up everyone listening to this flow right now. */
+ getActiveListeners(chatflowid: string): Promise
+
+ /**
+ * Bind an in-flight execution chatId to every listener of a flow. The webhook handler
+ * calls this right before invoking the flow so events emitted under `executionChatId` are
+ * observed by every listener — locally on this replica AND across replicas via pub/sub.
+ */
+ bindExecution(chatflowid: string, executionChatId: string): Promise
+
+ /** Get the id used to identify this replica in cross-replica messages. */
+ getReplicaId(): string
+
+ /** Optional shutdown hook (queue mode tears down Redis subscribers). */
+ dispose?(): Promise
+}
+
+/**
+ * Single-replica, in-memory registry. Used in MAIN mode where there is no cross-replica concern
+ * and Redis is not necessarily configured.
+ */
+export class InMemoryWebhookListenerRegistry implements IWebhookListenerRegistry {
+ private readonly replicaId = `main-${uuidv4()}`
+ private readonly listeners: Map> = new Map()
+ private readonly ttlMs: number
+ private readonly sseStreamer: SSEStreamer
+
+ constructor(sseStreamer: SSEStreamer, ttlMs = 120_000) {
+ this.sseStreamer = sseStreamer
+ this.ttlMs = ttlMs
+ }
+
+ getReplicaId(): string {
+ return this.replicaId
+ }
+
+ async register(chatflowid: string): Promise {
+ const listenerId = `wh-listener-${uuidv4()}`
+ this.scheduleEviction(chatflowid, listenerId)
+ return listenerId
+ }
+
+ async heartbeat(chatflowid: string, listenerId: string): Promise {
+ this.scheduleEviction(chatflowid, listenerId)
+ }
+
+ async unregister(chatflowid: string, listenerId: string): Promise {
+ const inner = this.listeners.get(chatflowid)
+ if (!inner) return
+ const handle = inner.get(listenerId)
+ if (handle) clearTimeout(handle)
+ inner.delete(listenerId)
+ if (inner.size === 0) this.listeners.delete(chatflowid)
+ }
+
+ async getActiveListeners(chatflowid: string): Promise {
+ const inner = this.listeners.get(chatflowid)
+ if (!inner || inner.size === 0) return []
+ return Array.from(inner.keys()).map((listenerId) => ({ listenerId, replicaId: this.replicaId }))
+ }
+
+ async bindExecution(chatflowid: string, executionChatId: string): Promise {
+ const listeners = await this.getActiveListeners(chatflowid)
+ for (const { listenerId } of listeners) {
+ this.sseStreamer.addObserver(executionChatId, listenerId)
+ }
+ }
+
+ private scheduleEviction(chatflowid: string, listenerId: string) {
+ let inner = this.listeners.get(chatflowid)
+ if (!inner) {
+ inner = new Map()
+ this.listeners.set(chatflowid, inner)
+ }
+ const existing = inner.get(listenerId)
+ if (existing) clearTimeout(existing)
+ const handle = setTimeout(() => {
+ this.unregister(chatflowid, listenerId).catch(() => {})
+ }, this.ttlMs)
+ inner.set(listenerId, handle)
+ }
+}
+
+/**
+ * Redis-backed registry. Listeners are kept in a per-flow hash with a refreshing TTL. Each
+ * replica boots subscribed to its own control channel; when a webhook fires on replica A and
+ * finds a listener on replica B, A publishes to B's channel telling it to observe events for
+ * the in-flight executionChatId on its local SSE client.
+ */
+export class RedisWebhookListenerRegistry implements IWebhookListenerRegistry {
+ private readonly replicaId = `replica-${uuidv4()}`
+ private readonly publisher: ReturnType
+ private readonly subscriber: ReturnType
+ private readonly sseStreamer: SSEStreamer
+ private readonly redisEventSubscriber: RedisEventSubscriber
+ private readonly ttlSeconds: number
+ private readonly listenerKey = (chatflowid: string) => `wh-listener:${chatflowid}`
+ private readonly bindChannel = (replicaId: string) => `wh-listener-bind:${replicaId}`
+
+ constructor(sseStreamer: SSEStreamer, redisEventSubscriber: RedisEventSubscriber, ttlSeconds = 120) {
+ this.sseStreamer = sseStreamer
+ this.redisEventSubscriber = redisEventSubscriber
+ this.ttlSeconds = ttlSeconds
+ this.publisher = createRedisClient()
+ this.subscriber = createRedisClient()
+ }
+
+ getReplicaId(): string {
+ return this.replicaId
+ }
+
+ async connect(): Promise {
+ await Promise.all([this.publisher.connect(), this.subscriber.connect()])
+
+ await this.subscriber.subscribe(this.bindChannel(this.replicaId), async (message) => {
+ try {
+ const parsed = JSON.parse(message) as { executionChatId?: string; listenerId?: string }
+ if (!parsed.executionChatId || !parsed.listenerId) return
+ // Only attach if the listener actually lives on this replica (sanity check —
+ // dispatcher already routed by replicaId, but the local SSE client is the
+ // ground truth).
+ if (!this.sseStreamer.hasClient(parsed.listenerId)) {
+ logger.warn(
+ `[WebhookListenerRegistry] Bind dropped: listener ${parsed.listenerId} not on this replica (${this.replicaId}). ` +
+ `Likely caused by ALB routing without sticky sessions, or by a webhook firing between register and stream.`
+ )
+ return
+ }
+
+ this.sseStreamer.addObserver(parsed.executionChatId, parsed.listenerId)
+ // Subscribe to the execution channel so the worker's published events land
+ // on this replica and get fanned out to the local listener client.
+ await this.redisEventSubscriber.subscribe(parsed.executionChatId)
+ } catch (err) {
+ logger.error('[WebhookListenerRegistry] Failed to handle bind notification', { error: err })
+ }
+ })
+
+ logger.info(`[WebhookListenerRegistry] Connected to Redis (replicaId=${this.replicaId})`)
+ }
+
+ async register(_chatflowid: string): Promise {
+ return `wh-listener-${uuidv4()}`
+ }
+
+ async heartbeat(chatflowid: string, listenerId: string): Promise {
+ // Re-set the field (idempotent) and bump the key's TTL so individual listeners staying
+ // connected keep the whole hash alive.
+ await this.publisher.hSet(this.listenerKey(chatflowid), listenerId, this.replicaId)
+ await this.publisher.expire(this.listenerKey(chatflowid), this.ttlSeconds)
+ }
+
+ async unregister(chatflowid: string, listenerId: string): Promise {
+ await this.publisher.hDel(this.listenerKey(chatflowid), listenerId)
+ }
+
+ async getActiveListeners(chatflowid: string): Promise {
+ const raw = await this.publisher.hGetAll(this.listenerKey(chatflowid))
+ return Object.entries(raw).map(([listenerId, replicaId]) => ({ listenerId, replicaId: String(replicaId) }))
+ }
+
+ async bindExecution(chatflowid: string, executionChatId: string): Promise {
+ const listeners = await this.getActiveListeners(chatflowid)
+ if (listeners.length === 0) return
+
+ for (const { listenerId, replicaId } of listeners) {
+ if (replicaId === this.replicaId) {
+ // Listener lives on this replica — attach the observer immediately, no pub/sub hop.
+ this.sseStreamer.addObserver(executionChatId, listenerId)
+ await this.redisEventSubscriber.subscribe(executionChatId)
+ } else {
+ await this.publisher.publish(this.bindChannel(replicaId), JSON.stringify({ executionChatId, listenerId }))
+ }
+ }
+ }
+
+ async dispose(): Promise {
+ try {
+ await this.subscriber.unsubscribe()
+ } catch {
+ /* ignore */
+ }
+ await Promise.allSettled([this.publisher.quit(), this.subscriber.quit()])
+ }
+}
+
+let registry: IWebhookListenerRegistry | null = null
+
+/**
+ * Build the right registry implementation for the current MODE. Called once during App init.
+ * Queue mode: Redis-backed, requires a connected RedisEventSubscriber. Otherwise: in-memory.
+ */
+export const initWebhookListenerRegistry = async (
+ sseStreamer: SSEStreamer,
+ redisEventSubscriber?: RedisEventSubscriber
+): Promise => {
+ if (process.env.MODE === MODE.QUEUE && redisEventSubscriber) {
+ const r = new RedisWebhookListenerRegistry(sseStreamer, redisEventSubscriber)
+ await r.connect()
+ registry = r
+ } else {
+ registry = new InMemoryWebhookListenerRegistry(sseStreamer)
+ }
+ return registry
+}
+
+export const getWebhookListenerRegistry = (): IWebhookListenerRegistry => {
+ if (!registry) {
+ throw new Error('WebhookListenerRegistry has not been initialized')
+ }
+ return registry
+}
diff --git a/packages/server/src/services/webhook/index.test.ts b/packages/server/src/services/webhook/index.test.ts
new file mode 100644
index 00000000000..3e7b9e80245
--- /dev/null
+++ b/packages/server/src/services/webhook/index.test.ts
@@ -0,0 +1,624 @@
+import { StatusCodes } from 'http-status-codes'
+import { InternalFlowiseError } from '../../errors/internalFlowiseError'
+
+const mockGetChatflowById = jest.fn()
+const mockGetWebhookSecret = jest.fn()
+
+jest.mock('../chatflows', () => ({
+ __esModule: true,
+ default: { getChatflowById: mockGetChatflowById, getWebhookSecret: mockGetWebhookSecret }
+}))
+
+import webhookService from './index'
+
+const makeChatflow = (
+ startInputType: string,
+ inputs?: {
+ webhookBodyParams?: unknown
+ webhookMethod?: string
+ webhookContentType?: string
+ webhookHeaderParams?: unknown
+ webhookQueryParams?: unknown
+ webhookEnableAuth?: boolean
+ webhookEnableCallback?: boolean
+ webhookEnableValidation?: boolean
+ callbackUrl?: string
+ callbackSecret?: string
+ },
+ entityFields?: { webhookSecretConfigured?: boolean }
+) => ({
+ id: 'test-id',
+ ...entityFields,
+ flowData: JSON.stringify({
+ nodes: [
+ {
+ id: 'startAgentflow_0',
+ data: {
+ name: 'startAgentflow',
+ inputs: {
+ startInputType,
+ ...inputs
+ }
+ }
+ }
+ ],
+ edges: []
+ })
+})
+
+describe('validateWebhookChatflow', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('throws 404 when chatflow is not found', async () => {
+ mockGetChatflowById.mockResolvedValue(null)
+
+ await expect(webhookService.validateWebhookChatflow('missing-id')).rejects.toMatchObject({
+ statusCode: StatusCodes.NOT_FOUND
+ })
+ })
+
+ it('throws 404 when chatflow is not configured as a webhook trigger', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('chatInput'))
+
+ await expect(webhookService.validateWebhookChatflow('some-id')).rejects.toMatchObject({
+ statusCode: StatusCodes.NOT_FOUND
+ })
+ })
+
+ it('resolves without error for a valid webhook chatflow', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger'))
+
+ await expect(webhookService.validateWebhookChatflow('some-id')).resolves.toMatchObject({})
+ })
+
+ it('throws 500 for unexpected errors from getChatflowById', async () => {
+ mockGetChatflowById.mockRejectedValue(new Error('db connection failed'))
+
+ await expect(webhookService.validateWebhookChatflow('some-id')).rejects.toMatchObject({
+ statusCode: StatusCodes.INTERNAL_SERVER_ERROR
+ })
+ })
+
+ it('preserves InternalFlowiseError without wrapping', async () => {
+ const original = new InternalFlowiseError(StatusCodes.NOT_FOUND, 'already an internal error')
+ mockGetChatflowById.mockRejectedValue(original)
+
+ await expect(webhookService.validateWebhookChatflow('some-id')).rejects.toBe(original)
+ })
+
+ // --- Method validation ---
+
+ it('throws 405 when HTTP method does not match configured webhookMethod', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookMethod: 'POST' }))
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'GET')).rejects.toMatchObject({
+ statusCode: StatusCodes.METHOD_NOT_ALLOWED
+ })
+ })
+
+ it('resolves for any method when webhookMethod is not configured', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger'))
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'DELETE')).resolves.toMatchObject({})
+ })
+
+ // --- Content-Type validation ---
+
+ it('throws 415 when Content-Type does not match configured webhookContentType', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' }))
+
+ await expect(
+ webhookService.validateWebhookChatflow('some-id', undefined, { foo: 'bar' }, 'POST', { 'content-type': 'text/plain' })
+ ).rejects.toMatchObject({ statusCode: StatusCodes.UNSUPPORTED_MEDIA_TYPE })
+ })
+
+ it('resolves when Content-Type starts with configured value (handles charset suffix)', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' }))
+
+ await expect(
+ webhookService.validateWebhookChatflow('some-id', undefined, { foo: 'bar' }, 'POST', {
+ 'content-type': 'application/json; charset=utf-8'
+ })
+ ).resolves.toMatchObject({})
+ })
+
+ it('resolves when webhookContentType is not configured', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger'))
+
+ await expect(
+ webhookService.validateWebhookChatflow('some-id', undefined, { foo: 'bar' }, 'POST', { 'content-type': 'text/plain' })
+ ).resolves.toMatchObject({})
+ })
+
+ it('skips Content-Type check when request has no body (empty POST ping)', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookContentType: 'application/json' }))
+
+ // Empty body, no Content-Type header — common for Postman pings before a body is filled in.
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {})).resolves.toMatchObject({})
+ })
+
+ // --- Header validation ---
+
+ it('throws 400 when a required header is missing', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookHeaderParams: [{ name: 'x-api-key', required: true }] })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {})).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('x-api-key')
+ })
+ })
+
+ it('resolves when all required headers are present', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookHeaderParams: [{ name: 'x-api-key', required: true }] })
+ )
+
+ await expect(
+ webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', { 'x-api-key': 'secret' })
+ ).resolves.toMatchObject({})
+ })
+
+ // --- Body param validation ---
+
+ it('throws 400 when a required param is missing from body', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST
+ })
+ })
+
+ it('includes the missing field name in the 400 error message', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('action')
+ })
+ })
+
+ it('resolves when all required params are present in body', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { action: 'push' })).resolves.toMatchObject({})
+ })
+
+ it('resolves when webhookBodyParams is empty string (DB default)', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: '' }))
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {})).resolves.toMatchObject({})
+ })
+
+ it('resolves when no params declared but body has arbitrary fields', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger'))
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { anything: 'goes' })).resolves.toMatchObject({})
+ })
+
+ // --- Body type validation ---
+
+ it('throws 400 when a declared body param has wrong type', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'count', type: 'number', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 'not-a-number' })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('count')
+ })
+ })
+
+ it('resolves when declared body param has correct type', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'count', type: 'number', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: 42 })).resolves.toMatchObject({})
+ })
+
+ it('resolves when number param is sent as a numeric string (form-encoded)', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'count', type: 'number', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '42' })).resolves.toMatchObject({})
+ })
+
+ it('throws 400 when number param is an empty string (form-encoded)', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'count', type: 'number', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { count: '' })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('count')
+ })
+ })
+
+ it('resolves when boolean param is a native boolean (JSON)', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: true })).resolves.toMatchObject({})
+ })
+
+ it('resolves when boolean param is the string "true" (form-encoded)', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'true' })).resolves.toMatchObject({})
+ })
+
+ it('resolves when boolean param is the string "false" (form-encoded)', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'false' })).resolves.toMatchObject({})
+ })
+
+ it('throws 400 when boolean param is an invalid string like "yes" (form-encoded)', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'active', type: 'boolean', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { active: 'yes' })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('active')
+ })
+ })
+
+ // --- object type ---
+
+ it('resolves when object param is a plain object', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'meta', type: 'object', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: { key: 'val' } })).resolves.toMatchObject({})
+ })
+
+ it('throws 400 when object param is a string', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'meta', type: 'object', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: 'hello' })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('meta')
+ })
+ })
+
+ it('throws 400 when object param is an array', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'meta', type: 'object', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { meta: [1, 2] })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('meta')
+ })
+ })
+
+ // --- array[string] type ---
+
+ it('resolves when array[string] param is an array of strings', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: ['a', 'b'] })).resolves.toMatchObject({})
+ })
+
+ it('throws 400 when array[string] param contains non-strings', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: [1, 2] })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('tags')
+ })
+ })
+
+ it('throws 400 when array[string] param is not an array', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'tags', type: 'array[string]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { tags: 'hello' })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('tags')
+ })
+ })
+
+ // --- array[number] type ---
+
+ it('resolves when array[number] param is an array of numbers', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { scores: [1, 2, 3] })).resolves.toMatchObject({})
+ })
+
+ it('throws 400 when array[number] param contains non-numbers', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'scores', type: 'array[number]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { scores: ['a', 'b'] })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('scores')
+ })
+ })
+
+ // --- array[boolean] type ---
+
+ it('resolves when array[boolean] param is an array of booleans', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { flags: [true, false] })).resolves.toMatchObject({})
+ })
+
+ it('throws 400 when array[boolean] param contains boolean strings (no coercion for array elements)', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'flags', type: 'array[boolean]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { flags: ['true', 'false'] })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('flags')
+ })
+ })
+
+ // --- array[object] type ---
+
+ it('resolves when array[object] param is an array of plain objects', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: [{ a: 1 }, { b: 2 }] })).resolves.toMatchObject(
+ {}
+ )
+ })
+
+ it('throws 400 when array[object] param contains non-objects', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: ['a', 'b'] })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('items')
+ })
+ })
+
+ it('throws 400 when array[object] param contains nested arrays', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', {
+ webhookEnableValidation: true,
+ webhookBodyParams: [{ name: 'items', type: 'array[object]', required: false }]
+ })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, { items: [[1, 2]] })).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('items')
+ })
+ })
+
+ // --- Query param validation ---
+
+ it('throws 400 when a required query param is missing', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookQueryParams: [{ name: 'page', required: true }] })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {})).rejects.toMatchObject({
+ statusCode: StatusCodes.BAD_REQUEST,
+ message: expect.stringContaining('page')
+ })
+ })
+
+ it('resolves when all required query params are present', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookQueryParams: [{ name: 'page', required: true }] })
+ )
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, { page: '2' })).resolves.toMatchObject({})
+ })
+
+ // --- HMAC signature verification ---
+
+ const SECRET = 'test-secret-abc123'
+ const RAW_BODY = Buffer.from('{"event":"push"}')
+
+ function sign(secret: string, body: Buffer): string {
+ const { createHmac } = require('crypto')
+ return createHmac('sha256', secret).update(body).digest('hex')
+ }
+
+ it('resolves without signature check when no webhookSecret is configured', async () => {
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger'))
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, RAW_BODY)).resolves.toMatchObject({})
+ })
+
+ it('resolves when secret is set and signature is valid', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true })
+ )
+ mockGetWebhookSecret.mockResolvedValue(SECRET)
+ const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) }
+
+ await expect(
+ webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, RAW_BODY)
+ ).resolves.toMatchObject({})
+ })
+
+ it('throws 401 when secret is set but X-Webhook-Signature header is missing', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true })
+ )
+ mockGetWebhookSecret.mockResolvedValue(SECRET)
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, RAW_BODY)).rejects.toMatchObject({
+ statusCode: 401,
+ message: expect.stringContaining('Missing signature header')
+ })
+ })
+
+ it('throws 500 with config hint when auth is enabled but no secret has been generated', async () => {
+ // Toggle on, but getWebhookSecret returns null (no secret generated yet)
+ mockGetChatflowById.mockResolvedValue(makeChatflow('webhookTrigger', { webhookEnableAuth: true }))
+ mockGetWebhookSecret.mockResolvedValue(null)
+ const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) }
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, RAW_BODY)).rejects.toMatchObject(
+ {
+ statusCode: 500,
+ message: expect.stringContaining('Generate Secret')
+ }
+ )
+ })
+
+ it('throws 401 when secret is set but signature is wrong', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true })
+ )
+ mockGetWebhookSecret.mockResolvedValue(SECRET)
+ const headers = { 'x-webhook-signature': 'deadbeef' }
+
+ await expect(webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, RAW_BODY)).rejects.toMatchObject(
+ {
+ statusCode: 401
+ }
+ )
+ })
+
+ it('throws 401 when payload is tampered (signature computed against original body)', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true })
+ )
+ mockGetWebhookSecret.mockResolvedValue(SECRET)
+ const tamperedBody = Buffer.from('{"event":"delete"}')
+ const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) }
+
+ await expect(
+ webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, tamperedBody)
+ ).rejects.toMatchObject({ statusCode: 401 })
+ })
+
+ it('throws 401 when secret is set but rawBody is undefined', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true })
+ )
+ mockGetWebhookSecret.mockResolvedValue(SECRET)
+ const headers = { 'x-webhook-signature': sign(SECRET, RAW_BODY) }
+
+ await expect(
+ webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', headers, {}, undefined)
+ ).rejects.toMatchObject({ statusCode: 401 })
+ })
+
+ // --- skipFieldValidation option (resume calls) ---
+
+ it('skips field validation when skipFieldValidation is true', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableValidation: true, webhookBodyParams: [{ name: 'action', required: true }] })
+ )
+
+ // Missing required body param 'action' — would normally throw 400, but not on resume
+ await expect(
+ webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, undefined, { skipFieldValidation: true })
+ ).resolves.toMatchObject({})
+ })
+
+ it('still runs signature check when skipFieldValidation is true', async () => {
+ mockGetChatflowById.mockResolvedValue(
+ makeChatflow('webhookTrigger', { webhookEnableAuth: true }, { webhookSecretConfigured: true })
+ )
+ mockGetWebhookSecret.mockResolvedValue(SECRET)
+
+ // No signature header — should still 401 even with skipFieldValidation
+ await expect(
+ webhookService.validateWebhookChatflow('some-id', undefined, {}, 'POST', {}, {}, RAW_BODY, { skipFieldValidation: true })
+ ).rejects.toMatchObject({ statusCode: 401 })
+ })
+})
diff --git a/packages/server/src/services/webhook/index.ts b/packages/server/src/services/webhook/index.ts
new file mode 100644
index 00000000000..e76486e7ba7
--- /dev/null
+++ b/packages/server/src/services/webhook/index.ts
@@ -0,0 +1,166 @@
+import { StatusCodes } from 'http-status-codes'
+import { IReactFlowObject } from '../../Interface'
+import { InternalFlowiseError } from '../../errors/internalFlowiseError'
+import { getErrorMessage } from '../../errors/utils'
+import { verifyWebhookSignature, verifyPlainToken } from '../../utils/signatureVerification'
+import chatflowsService from '../chatflows'
+
+const validateWebhookChatflow = async (
+ chatflowId: string,
+ workspaceId?: string,
+ body?: Record,
+ method?: string,
+ headers?: Record,
+ query?: Record,
+ rawBody?: Buffer,
+ options?: { skipFieldValidation?: boolean }
+): Promise<{ responseMode: 'sync' | 'async' | 'stream'; callbackUrl?: string; callbackSecret?: string }> => {
+ try {
+ const chatflow = await chatflowsService.getChatflowById(chatflowId, workspaceId)
+ if (!chatflow) {
+ throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} not found`)
+ }
+
+ const parsedFlowData: IReactFlowObject = JSON.parse(chatflow.flowData)
+ const startNode = parsedFlowData.nodes.find((node) => node.data.name === 'startAgentflow')
+ const startInputType = startNode?.data?.inputs?.startInputType
+
+ if (startInputType !== 'webhookTrigger') {
+ throw new InternalFlowiseError(StatusCodes.NOT_FOUND, `Chatflow ${chatflowId} is not configured as a webhook trigger`)
+ }
+
+ const enableAuth = startNode?.data?.inputs?.webhookEnableAuth === true
+ const enableValidation = startNode?.data?.inputs?.webhookEnableValidation === true
+ // 'sync' (default) returns JSON when the flow finishes, 'async' returns 202 + optional
+ // callback POST, 'stream' returns an SSE stream of token/step events.
+ const rawResponseMode = startNode?.data?.inputs?.webhookResponseMode as string | undefined
+ const responseMode: 'sync' | 'async' | 'stream' =
+ rawResponseMode === 'async' || rawResponseMode === 'stream' ? rawResponseMode : 'sync'
+
+ // callbackUrl is only meaningful in async mode — when omitted there, the flow runs
+ // fire-and-forget (202 returned, no callback delivered).
+ const callbackUrl = responseMode === 'async' ? (startNode?.data?.inputs?.callbackUrl as string | undefined) || undefined : undefined
+ const callbackSecret =
+ responseMode === 'async' ? (startNode?.data?.inputs?.callbackSecret as string | undefined) || undefined : undefined
+
+ // Signature verification (runs before any other validation to fail-fast on bad auth)
+ if (enableAuth) {
+ const secret = await chatflowsService.getWebhookSecret(chatflowId, chatflow.workspaceId)
+ if (!secret) {
+ throw new InternalFlowiseError(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ 'Webhook signature verification is enabled but no secret has been generated. Open the Start node and click Generate Secret.'
+ )
+ }
+
+ const sigHeader = ((startNode?.data?.inputs?.webhookSignatureHeader as string) || 'x-webhook-signature').toLowerCase()
+ const sigType = (startNode?.data?.inputs?.webhookSignatureType as string) || 'hmac-sha256'
+ const sigValue = (headers?.[sigHeader] ?? '') as string
+
+ if (!sigValue) {
+ throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, `Missing signature header: ${sigHeader}`)
+ }
+
+ const valid =
+ sigType === 'plain-token'
+ ? verifyPlainToken(secret, sigValue)
+ : !!rawBody && verifyWebhookSignature(secret, rawBody, sigValue)
+
+ if (!valid) {
+ throw new InternalFlowiseError(StatusCodes.UNAUTHORIZED, 'Invalid webhook signature')
+ }
+ }
+
+ if (options?.skipFieldValidation) return { responseMode, callbackUrl, callbackSecret }
+
+ // Method validation
+ const webhookMethod = startNode?.data?.inputs?.webhookMethod
+ if (webhookMethod && method?.toUpperCase() !== webhookMethod.toUpperCase()) {
+ throw new InternalFlowiseError(StatusCodes.METHOD_NOT_ALLOWED, `Method ${method} not allowed. Expected ${webhookMethod}`)
+ }
+
+ // Content-Type validation
+ const hasBody = (rawBody && rawBody.length > 0) || (body != null && Object.keys(body).length > 0)
+ const webhookContentType = startNode?.data?.inputs?.webhookContentType
+ const incomingContentType = (headers?.['content-type'] ?? '').toLowerCase()
+ if (webhookContentType && hasBody && !incomingContentType.startsWith(webhookContentType)) {
+ throw new InternalFlowiseError(
+ StatusCodes.UNSUPPORTED_MEDIA_TYPE,
+ `Content-Type ${headers?.['content-type']} not allowed. Expected ${webhookContentType}`
+ )
+ }
+
+ // Header / body / query shape validation runs only when the user has explicitly opted in.
+ if (enableValidation) {
+ // Required header validation
+ const rawHeaderParams = startNode?.data?.inputs?.webhookHeaderParams
+ const webhookHeaderParams: Array<{ name: string; required: boolean }> = Array.isArray(rawHeaderParams) ? rawHeaderParams : []
+ const missingHeaders = webhookHeaderParams
+ .filter((p) => p.required && headers?.[p.name.toLowerCase()] == null)
+ .map((p) => p.name)
+ if (missingHeaders.length > 0) {
+ throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Missing required headers: ${missingHeaders.join(', ')}`)
+ }
+
+ // Required body param validation
+ const rawBodyParams = startNode?.data?.inputs?.webhookBodyParams
+ const webhookBodyParams: Array<{ name: string; type: string; required: boolean }> = Array.isArray(rawBodyParams)
+ ? rawBodyParams
+ : []
+ const missingParams = webhookBodyParams.filter((p) => p.required && body?.[p.name] == null).map((p) => p.name)
+ if (missingParams.length > 0) {
+ throw new InternalFlowiseError(
+ StatusCodes.BAD_REQUEST,
+ `Missing required webhook body parameters: ${missingParams.join(', ')}`
+ )
+ }
+
+ // Body type validation (only for params that have an explicit type declared)
+ const typeMismatch = webhookBodyParams
+ .filter((p) => {
+ if (p.type == null || body?.[p.name] == null) return false
+ const val = body[p.name]
+ if (p.type === 'number') return val === '' || isNaN(Number(val))
+ if (p.type === 'boolean') return typeof val !== 'boolean' && val !== 'true' && val !== 'false'
+ if (p.type === 'object') return typeof val !== 'object' || val === null || Array.isArray(val)
+ if (p.type === 'array[string]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'string')
+ if (p.type === 'array[number]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'number')
+ if (p.type === 'array[boolean]') return !Array.isArray(val) || (val as unknown[]).some((el) => typeof el !== 'boolean')
+ if (p.type === 'array[object]')
+ return (
+ !Array.isArray(val) ||
+ (val as unknown[]).some((el) => typeof el !== 'object' || el === null || Array.isArray(el))
+ )
+ return typeof val !== p.type
+ })
+ .map((p) => p.name)
+
+ if (typeMismatch.length > 0) {
+ throw new InternalFlowiseError(StatusCodes.BAD_REQUEST, `Invalid type for parameter(s): ${typeMismatch.join(', ')}`)
+ }
+
+ // Required query param validation
+ const rawQueryParams = startNode?.data?.inputs?.webhookQueryParams
+ const webhookQueryParams: Array<{ name: string; required: boolean }> = Array.isArray(rawQueryParams) ? rawQueryParams : []
+ const missingQueryParams = webhookQueryParams.filter((p) => p.required && query?.[p.name] == null).map((p) => p.name)
+ if (missingQueryParams.length > 0) {
+ throw new InternalFlowiseError(
+ StatusCodes.BAD_REQUEST,
+ `Missing required query parameters: ${missingQueryParams.join(', ')}`
+ )
+ }
+ }
+
+ return { responseMode, callbackUrl, callbackSecret }
+ } catch (error) {
+ if (error instanceof InternalFlowiseError) throw error
+ throw new InternalFlowiseError(
+ StatusCodes.INTERNAL_SERVER_ERROR,
+ `Error: webhookService.validateWebhookChatflow - ${getErrorMessage(error)}`
+ )
+ }
+}
+
+export default {
+ validateWebhookChatflow
+}
diff --git a/packages/server/src/utils/SSEStreamer.ts b/packages/server/src/utils/SSEStreamer.ts
index ef174fc59ab..feee99af695 100644
--- a/packages/server/src/utils/SSEStreamer.ts
+++ b/packages/server/src/utils/SSEStreamer.ts
@@ -12,12 +12,44 @@ type Client = {
export class SSEStreamer implements IServerSideEventStreamer {
private readonly clients: Map = new Map()
+ // Observers receive a passive copy of every event written for `sourceChatId` — one source,
+ // many destinations. Use cases: webhook listener panels watching an in-flight execution,
+ // multi-tab chat sync, admin shadowing, test/eval harnesses. Per-replica only — cross-replica
+ // fan-out happens upstream by having the observer's replica subscribe to the source chatId itself.
+ private readonly observers: Map> = new Map()
private heartbeatInterval: NodeJS.Timeout | null = null
hasClient(chatId: string): boolean {
return this.clients.has(chatId)
}
+ /**
+ * True when there's either a real client or at least one active observer for this chatId.
+ */
+ hasClientOrObserver(chatId: string): boolean {
+ return this.clients.has(chatId) || (this.observers.get(chatId)?.size ?? 0) > 0
+ }
+
+ addObserver(sourceChatId: string, observerId: string) {
+ let set = this.observers.get(sourceChatId)
+ if (!set) {
+ set = new Set()
+ this.observers.set(sourceChatId, set)
+ }
+ set.add(observerId)
+ }
+
+ removeObserver(sourceChatId: string, observerId: string) {
+ const set = this.observers.get(sourceChatId)
+ if (!set) return
+ set.delete(observerId)
+ if (set.size === 0) this.observers.delete(sourceChatId)
+ }
+
+ clearObservers(sourceChatId: string) {
+ this.observers.delete(sourceChatId)
+ }
+
addExternalClient(chatId: string, res: Response) {
this.clients.set(chatId, { clientType: 'EXTERNAL', response: res, started: false })
}
@@ -29,17 +61,39 @@ export class SSEStreamer implements IServerSideEventStreamer {
/**
* Safely write data to a client's response. If the write fails (e.g., client already disconnected),
* the client is automatically removed to prevent further writes to a dead connection.
+ * Also fans out to any registered observers of `chatId`.
*/
private safeWrite(chatId: string, data: string): boolean {
const client = this.clients.get(chatId)
- if (!client) return false
- try {
- client.response.write(data)
- return true
- } catch {
- this.clients.delete(chatId)
- return false
+ let ok = false
+ if (client) {
+ try {
+ client.response.write(data)
+ ok = true
+ } catch {
+ this.clients.delete(chatId)
+ }
}
+
+ const observerSet = this.observers.get(chatId)
+ if (observerSet && observerSet.size > 0) {
+ for (const observerId of Array.from(observerSet)) {
+ const observer = this.clients.get(observerId)
+ if (!observer) {
+ observerSet.delete(observerId)
+ continue
+ }
+ try {
+ observer.response.write(data)
+ } catch {
+ this.clients.delete(observerId)
+ observerSet.delete(observerId)
+ }
+ }
+ if (observerSet.size === 0) this.observers.delete(chatId)
+ }
+
+ return ok
}
removeClient(chatId: string) {
@@ -58,6 +112,32 @@ export class SSEStreamer implements IServerSideEventStreamer {
this.clients.delete(chatId)
}
}
+
+ // Notify any observers that this execution finished, but keep their long-lived
+ // connections open for whatever they're observing next. UI transitions in_progress → done → idle.
+ const observerSet = this.observers.get(chatId)
+ if (observerSet && observerSet.size > 0) {
+ for (const observerId of Array.from(observerSet)) {
+ const observer = this.clients.get(observerId)
+ if (!observer) continue
+ try {
+ const payload = { event: 'executionEnd', data: { chatId } }
+ observer.response.write('message:\ndata:' + JSON.stringify(payload) + '\n\n')
+ } catch {
+ this.clients.delete(observerId)
+ }
+ }
+ this.observers.delete(chatId)
+ }
+
+ // If the removed `chatId` was itself an observer, scrub it from every observer Set that
+ // still references it. Otherwise stale references would sit in memory until the next
+ // write to each observed chatId organically failed and lazily cleaned them up.
+ for (const [sourceId, observerIds] of this.observers) {
+ if (observerIds.delete(chatId) && observerIds.size === 0) {
+ this.observers.delete(sourceId)
+ }
+ }
}
streamCustomEvent(chatId: string, eventType: string, data: any) {
diff --git a/packages/server/src/utils/buildAgentflow.ts b/packages/server/src/utils/buildAgentflow.ts
index 3c4a451a17b..248d3ed82a2 100644
--- a/packages/server/src/utils/buildAgentflow.ts
+++ b/packages/server/src/utils/buildAgentflow.ts
@@ -95,6 +95,28 @@ interface IAgentFlowRuntime {
state?: ICommonObject
chatHistory?: IMessage[]
form?: Record
+ webhook?: Record
+}
+
+/**
+ * Resolves {{ $webhook.body.* }}, {{ $webhook.headers.* }}, {{ $webhook.query.* }} references in a
+ * template string against an incoming webhook payload. Used to pre-resolve webhookDefaultInput
+ * before any node runs, so the Start node's run() and downstream finalInput see the same value.
+ * Unknown references are left as-is.
+ */
+const resolveWebhookRefs = (template: string, webhook: Record | undefined | null): string => {
+ if (!template) return ''
+ if (!webhook) return template
+ return template.replace(/{{(.*?)}}/g, (match, ref) => {
+ const path = ref.trim()
+ if (!path.startsWith('$webhook.')) return match
+ // Block prototype-walking paths defensively — lodash.get follows __proto__/constructor/prototype.
+ const subPath = path.replace('$webhook.', '')
+ if (/(^|\.)(__proto__|constructor|prototype)(\.|$)/.test(subPath)) return match
+ const val = get(webhook, subPath)
+ if (val == null) return match
+ return Array.isArray(val) || (typeof val === 'object' && val !== null) ? JSON.stringify(val) : String(val)
+ })
}
interface IExecuteNodeParams {
@@ -225,7 +247,8 @@ export const resolveVariables = async (
componentNodes: IComponentNodes,
agentFlowExecutedData?: IAgentflowExecutedData[],
iterationContext?: ICommonObject,
- loopCounts?: Map
+ loopCounts?: Map,
+ webhook?: Record
): Promise => {
let flowNodeData = cloneDeep(reactFlowNodeData)
const types = 'inputs'
@@ -286,6 +309,17 @@ export const resolveVariables = async (
}
}
+ if (variableFullPath.startsWith('$webhook.')) {
+ const variableValue = get(webhook, variableFullPath.replace('$webhook.', ''))
+ if (variableValue != null) {
+ const formattedValue =
+ Array.isArray(variableValue) || (typeof variableValue === 'object' && variableValue !== null)
+ ? JSON.stringify(variableValue)
+ : variableValue
+ resolvedValue = resolvedValue.replace(match, formattedValue)
+ }
+ }
+
if (variableFullPath === FILE_ATTACHMENT_PREFIX) {
resolvedValue = resolvedValue.replace(match, uploadedFilesContent)
}
@@ -1124,6 +1158,12 @@ const executeNode = async ({
} else if (isObjectNotEmpty(agentflowRuntime.form)) {
formValue = agentflowRuntime.form as Record
}
+ let webhookValue: Record = {}
+ if (isObjectNotEmpty(incomingInput.webhook)) {
+ webhookValue = incomingInput.webhook as Record
+ } else if (isObjectNotEmpty(agentflowRuntime.webhook)) {
+ webhookValue = agentflowRuntime.webhook as Record
+ }
const reactFlowNodeData: INodeData = await resolveVariables(
flowNodeData,
incomingInput.question ?? '',
@@ -1136,7 +1176,8 @@ const executeNode = async ({
componentNodes,
agentFlowExecutedData,
iterationContext,
- loopCounts
+ loopCounts,
+ webhookValue
)
// Handle human input if present
@@ -1176,6 +1217,8 @@ const executeNode = async ({
finalInput = Object.entries(incomingInput.form || {})
.map(([key, value]) => `${key}: ${value}`)
.join('\n')
+ } else if (incomingInput.webhook) {
+ finalInput = JSON.stringify(incomingInput.webhook)
}
// Prepare run parameters
@@ -1549,9 +1592,8 @@ export const executeAgentFlow = async ({
const edges = parsedFlowData.edges
const { graph, nodeDependencies } = constructGraphs(nodes, edges)
const { graph: reversedGraph } = constructGraphs(nodes, edges, { isReversed: true })
- const startInputType = nodes.find((node) => node.data.name === 'startAgentflow')?.data.inputs?.startInputType as
- | 'chatInput'
- | 'formInput'
+ const startNode = nodes.find((node) => node.data.name === 'startAgentflow')
+ const startInputType = startNode?.data.inputs?.startInputType as 'chatInput' | 'formInput' | 'webhookTrigger'
if (!startInputType && !isRecursive) {
throw new Error('Start input type not found')
}
@@ -1595,7 +1637,8 @@ export const executeAgentFlow = async ({
let agentflowRuntime: IAgentFlowRuntime = {
state: {},
chatHistory: [],
- form: {}
+ form: {},
+ webhook: {}
}
let previousExecution: Execution | undefined
@@ -1678,6 +1721,37 @@ export const executeAgentFlow = async ({
}
}
+ // On webhook humanInput resume, restore the original trigger's webhook data so $webhook.body.*,
+ // $webhook.headers.*, $webhook.query.* and $flow.input resolve to the original trigger values.
+ // incomingInput.webhook is always present on webhook calls so we overwrite it directly rather
+ // than relying on the agentflowRuntime.webhook fallback (unlike the formInput pattern).
+ if (startInputType === 'webhookTrigger' && humanInput && previousExecution) {
+ const previousExecutionData = (JSON.parse(previousExecution.executionData) as IAgentflowExecutedData[]) ?? []
+
+ const previousStartAgent = previousExecutionData.find((execData) => execData.data.name === 'startAgentflow')
+
+ if (previousStartAgent) {
+ const previousStartAgentOutput = previousStartAgent.data.output
+ if (previousStartAgentOutput && typeof previousStartAgentOutput === 'object' && 'webhook' in previousStartAgentOutput) {
+ incomingInput.webhook = previousStartAgentOutput.webhook as Record
+ }
+ }
+ }
+
+ if (startInputType === 'webhookTrigger' && !humanInput) {
+ const webhookInputMode = (startNode?.data?.inputs?.webhookInputMode as string) || 'text'
+ if (webhookInputMode === 'text') {
+ const template = (startNode?.data?.inputs?.webhookDefaultInput as string) || ''
+ incomingInput.question = resolveWebhookRefs(template, incomingInput.webhook) || ' '
+ } else if (webhookInputMode === 'none') {
+ incomingInput.question = ' '
+ }
+ }
+
+ if (incomingInput.webhook && Object.keys(incomingInput.webhook).length) {
+ agentflowRuntime.webhook = incomingInput.webhook
+ }
+
// If it is human input, find the last checkpoint and resume
// Skip human input resumption for recursive iteration calls - they should start fresh
if (humanInput && !(isRecursive && iterationContext)) {
@@ -2259,6 +2333,13 @@ export const executeAgentFlow = async ({
} else {
finalUserInput = question || humanInput?.feedback || ' '
}
+ } else if (startInputType === 'webhookTrigger') {
+ const webhookInputMode = (startNode?.data?.inputs?.webhookInputMode as string) || 'text'
+ if (webhookInputMode === 'payload') {
+ finalUserInput = incomingInput.webhook ? JSON.stringify(incomingInput.webhook) : ' '
+ } else {
+ finalUserInput = humanInput?.feedback || incomingInput.question || ' '
+ }
}
const userMessage: Omit = {
diff --git a/packages/server/src/utils/callbackDispatcher.test.ts b/packages/server/src/utils/callbackDispatcher.test.ts
new file mode 100644
index 00000000000..8a5525fb27c
--- /dev/null
+++ b/packages/server/src/utils/callbackDispatcher.test.ts
@@ -0,0 +1,92 @@
+import { createHmac } from 'crypto'
+
+const mockSecureAxiosRequest = jest.fn()
+const mockLoggerError = jest.fn()
+
+jest.mock('flowise-components', () => ({ secureAxiosRequest: mockSecureAxiosRequest }))
+jest.mock('./logger', () => ({ error: mockLoggerError }))
+
+import { dispatchCallback } from './callbackDispatcher'
+
+const URL = 'https://example.com/callback'
+const PAYLOAD = { status: 'SUCCESS', chatId: 'abc-123', data: { text: 'hello' } }
+
+function expectedSignature(body: string, secret: string): string {
+ return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex')
+}
+
+describe('dispatchCallback', () => {
+ beforeEach(() => {
+ jest.clearAllMocks()
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.useRealTimers()
+ })
+
+ it('POSTs JSON payload to the callback URL via secureAxiosRequest', async () => {
+ mockSecureAxiosRequest.mockResolvedValue({ status: 200 })
+
+ await dispatchCallback(URL, PAYLOAD)
+
+ expect(mockSecureAxiosRequest).toHaveBeenCalledTimes(1)
+ expect(mockSecureAxiosRequest).toHaveBeenCalledWith({
+ method: 'POST',
+ url: URL,
+ data: JSON.stringify(PAYLOAD),
+ headers: { 'Content-Type': 'application/json' },
+ timeout: 10000
+ })
+ })
+
+ it('includes X-Flowise-Signature header when secret is provided', async () => {
+ mockSecureAxiosRequest.mockResolvedValue({ status: 200 })
+ const secret = 'my-secret'
+ const body = JSON.stringify(PAYLOAD)
+
+ await dispatchCallback(URL, PAYLOAD, secret)
+
+ expect(mockSecureAxiosRequest).toHaveBeenCalledWith(
+ expect.objectContaining({
+ url: URL,
+ data: body,
+ headers: expect.objectContaining({
+ 'X-Flowise-Signature': expectedSignature(body, secret)
+ })
+ })
+ )
+ })
+
+ it('does not include X-Flowise-Signature when no secret is provided', async () => {
+ mockSecureAxiosRequest.mockResolvedValue({ status: 200 })
+
+ await dispatchCallback(URL, PAYLOAD)
+
+ const call = mockSecureAxiosRequest.mock.calls[0]
+ expect(call[0].headers).not.toHaveProperty('X-Flowise-Signature')
+ })
+
+ it('retries on failure and succeeds on second attempt', async () => {
+ mockSecureAxiosRequest.mockRejectedValueOnce(new Error('timeout')).mockResolvedValue({ status: 200 })
+
+ const promise = dispatchCallback(URL, PAYLOAD)
+ await jest.advanceTimersByTimeAsync(3000)
+ await promise
+
+ expect(mockSecureAxiosRequest).toHaveBeenCalledTimes(2)
+ expect(mockLoggerError).not.toHaveBeenCalled()
+ })
+
+ it('logs an error after all 3 attempts fail and does not throw', async () => {
+ mockSecureAxiosRequest.mockRejectedValue(new Error('unreachable'))
+
+ const promise = dispatchCallback(URL, PAYLOAD)
+ await jest.advanceTimersByTimeAsync(3000)
+ await jest.advanceTimersByTimeAsync(6000)
+ await promise
+
+ expect(mockSecureAxiosRequest).toHaveBeenCalledTimes(3)
+ expect(mockLoggerError).toHaveBeenCalledWith(expect.stringContaining('Failed to deliver callback'))
+ })
+})
diff --git a/packages/server/src/utils/callbackDispatcher.ts b/packages/server/src/utils/callbackDispatcher.ts
new file mode 100644
index 00000000000..71400b2b4c4
--- /dev/null
+++ b/packages/server/src/utils/callbackDispatcher.ts
@@ -0,0 +1,32 @@
+import { createHmac } from 'crypto'
+import { secureAxiosRequest } from 'flowise-components'
+import logger from './logger'
+
+// Delays in ms before each attempt: attempt 1 is immediate, attempt 2 waits 3s, attempt 3 waits 6s
+const RETRY_DELAYS = [0, 3000, 6000]
+
+function sign(body: string, secret: string): string {
+ return 'sha256=' + createHmac('sha256', secret).update(body).digest('hex')
+}
+
+export async function dispatchCallback(url: string, payload: Record, secret?: string): Promise {
+ const body = JSON.stringify(payload)
+ const headers: Record = { 'Content-Type': 'application/json' }
+ if (secret) headers['X-Flowise-Signature'] = sign(body, secret)
+
+ for (let attempt = 0; attempt < RETRY_DELAYS.length; attempt++) {
+ if (RETRY_DELAYS[attempt] > 0) {
+ await new Promise((r) => setTimeout(r, RETRY_DELAYS[attempt]))
+ }
+ try {
+ await secureAxiosRequest({ method: 'POST', url, data: body, headers, timeout: 10000 })
+ return
+ } catch (err: any) {
+ if (attempt === RETRY_DELAYS.length - 1) {
+ logger.error(
+ `[callbackDispatcher] Failed to deliver callback to ${url} after ${RETRY_DELAYS.length} attempts: ${err.message}`
+ )
+ }
+ }
+ }
+}
diff --git a/packages/server/src/utils/constants.ts b/packages/server/src/utils/constants.ts
index 900a716b4a1..a20f55c4c43 100644
--- a/packages/server/src/utils/constants.ts
+++ b/packages/server/src/utils/constants.ts
@@ -10,6 +10,7 @@ export const WHITELIST_URLS = [
'/api/v1/public-chatbotConfig',
'/api/v1/public-executions',
'/api/v1/prediction/',
+ '/api/v1/webhook/',
'/api/v1/chatmessage/abort',
'/api/v1/node-icon/',
'/api/v1/components-credentials-icon/',
diff --git a/packages/server/src/utils/redis.ts b/packages/server/src/utils/redis.ts
new file mode 100644
index 00000000000..e1b516a44dd
--- /dev/null
+++ b/packages/server/src/utils/redis.ts
@@ -0,0 +1,36 @@
+import { createClient } from 'redis'
+
+export const buildRedisClientOptions = (): Parameters[0] => {
+ const keepAliveRaw = process.env.REDIS_KEEP_ALIVE
+ const keepAliveMs = keepAliveRaw && !isNaN(parseInt(keepAliveRaw, 10)) ? parseInt(keepAliveRaw, 10) : undefined
+
+ if (process.env.REDIS_URL) {
+ return {
+ url: process.env.REDIS_URL,
+ socket: { keepAlive: keepAliveMs },
+ pingInterval: keepAliveMs
+ }
+ }
+
+ return {
+ username: process.env.REDIS_USERNAME || undefined,
+ password: process.env.REDIS_PASSWORD || undefined,
+ socket: {
+ host: process.env.REDIS_HOST || 'localhost',
+ port: parseInt(process.env.REDIS_PORT || '6379'),
+ tls: process.env.REDIS_TLS === 'true',
+ cert: process.env.REDIS_CERT ? Buffer.from(process.env.REDIS_CERT, 'base64') : undefined,
+ key: process.env.REDIS_KEY ? Buffer.from(process.env.REDIS_KEY, 'base64') : undefined,
+ ca: process.env.REDIS_CA ? Buffer.from(process.env.REDIS_CA, 'base64') : undefined,
+ keepAlive: keepAliveMs
+ },
+ pingInterval: keepAliveMs
+ }
+}
+
+/**
+ * Convenience wrapper that returns a fresh, **un-connected** node-redis client built
+ * with the standard env-driven options. Callers still own the connection lifecycle
+ * (`.connect()`, `.quit()`, error listeners).
+ */
+export const createRedisClient = (): ReturnType => createClient(buildRedisClientOptions())
diff --git a/packages/server/src/utils/signatureVerification.test.ts b/packages/server/src/utils/signatureVerification.test.ts
new file mode 100644
index 00000000000..093c9b5493d
--- /dev/null
+++ b/packages/server/src/utils/signatureVerification.test.ts
@@ -0,0 +1,103 @@
+import { createHmac } from 'crypto'
+import { verifyWebhookSignature, verifyPlainToken } from './signatureVerification'
+
+const SECRET = 'test-secret-abc123'
+const BODY = Buffer.from('{"event":"push"}')
+
+function sign(secret: string, body: Buffer): string {
+ return createHmac('sha256', secret).update(new Uint8Array(body)).digest('hex')
+}
+
+describe('verifyWebhookSignature', () => {
+ it('returns true for a valid signature', () => {
+ expect(verifyWebhookSignature(SECRET, BODY, sign(SECRET, BODY))).toBe(true)
+ })
+
+ it('returns false for a wrong secret', () => {
+ expect(verifyWebhookSignature('wrong-secret', BODY, sign(SECRET, BODY))).toBe(false)
+ })
+
+ it('returns false for a tampered body', () => {
+ const tamperedBody = Buffer.from('{"event":"delete"}')
+ expect(verifyWebhookSignature(SECRET, tamperedBody, sign(SECRET, BODY))).toBe(false)
+ })
+
+ it('returns false for an empty signature string', () => {
+ expect(verifyWebhookSignature(SECRET, BODY, '')).toBe(false)
+ })
+
+ it('returns false for a non-hex signature string', () => {
+ expect(verifyWebhookSignature(SECRET, BODY, 'not-hex!!')).toBe(false)
+ })
+
+ it('returns false for a signature that is too short', () => {
+ const truncated = sign(SECRET, BODY).slice(0, 10)
+ expect(verifyWebhookSignature(SECRET, BODY, truncated)).toBe(false)
+ })
+
+ it('returns false for a signature that is too long', () => {
+ const padded = sign(SECRET, BODY) + 'aabb'
+ expect(verifyWebhookSignature(SECRET, BODY, padded)).toBe(false)
+ })
+
+ it('returns true for an empty body when signed correctly', () => {
+ const emptyBody = Buffer.from('')
+ expect(verifyWebhookSignature(SECRET, emptyBody, sign(SECRET, emptyBody))).toBe(true)
+ })
+
+ describe('sha256= prefix', () => {
+ it('returns true for a valid sha256= signature', () => {
+ const sig = 'sha256=' + sign(SECRET, BODY)
+ expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(true)
+ })
+
+ it('returns false for sha256= with wrong secret', () => {
+ const sig = 'sha256=' + sign('wrong-secret', BODY)
+ expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false)
+ })
+
+ it('returns false for sha256= with tampered body', () => {
+ const sig = 'sha256=' + sign(SECRET, Buffer.from('{"event":"delete"}'))
+ expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false)
+ })
+ })
+
+ describe('sha1= prefix', () => {
+ function signSha1(secret: string, body: Buffer): string {
+ return createHmac('sha1', secret).update(new Uint8Array(body)).digest('hex')
+ }
+
+ it('returns true for a valid sha1= signature', () => {
+ const sig = 'sha1=' + signSha1(SECRET, BODY)
+ expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(true)
+ })
+
+ it('returns false for sha1= with wrong secret', () => {
+ const sig = 'sha1=' + signSha1('wrong-secret', BODY)
+ expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false)
+ })
+
+ it('returns false for sha1= with tampered body', () => {
+ const sig = 'sha1=' + signSha1(SECRET, Buffer.from('{"event":"delete"}'))
+ expect(verifyWebhookSignature(SECRET, BODY, sig)).toBe(false)
+ })
+ })
+})
+
+describe('verifyPlainToken', () => {
+ it('returns true when provided token matches secret', () => {
+ expect(verifyPlainToken(SECRET, SECRET)).toBe(true)
+ })
+
+ it('returns false when provided token does not match secret', () => {
+ expect(verifyPlainToken(SECRET, 'wrong-token')).toBe(false)
+ })
+
+ it('returns false when lengths differ (shorter provided)', () => {
+ expect(verifyPlainToken(SECRET, SECRET.slice(0, -1))).toBe(false)
+ })
+
+ it('returns false when lengths differ (longer provided)', () => {
+ expect(verifyPlainToken(SECRET, SECRET + 'x')).toBe(false)
+ })
+})
diff --git a/packages/server/src/utils/signatureVerification.ts b/packages/server/src/utils/signatureVerification.ts
new file mode 100644
index 00000000000..00f7118c8c5
--- /dev/null
+++ b/packages/server/src/utils/signatureVerification.ts
@@ -0,0 +1,50 @@
+import { createHmac, timingSafeEqual } from 'crypto'
+
+/**
+ * Verifies that `providedHex` is the HMAC of `rawBody` keyed with `secret`.
+ * Uses constant-time comparison to prevent timing attacks.
+ *
+ * Automatically detects the algorithm from a leading "=" prefix:
+ * - "sha256=" → HMAC-SHA256 (GitHub X-Hub-Signature-256, Slack, Bitbucket)
+ * - "sha1=" → HMAC-SHA1 (GitHub X-Hub-Signature legacy)
+ * - no prefix → HMAC-SHA256 (default)
+ *
+ * @param secret The webhook secret stored on the chatflow
+ * @param rawBody The raw request body bytes
+ * @param providedHex The hex digest (optionally prefixed) from the signature request header
+ * @returns true if the signature is valid, false otherwise
+ */
+export function verifyWebhookSignature(secret: string, rawBody: Buffer, providedHex: string): boolean {
+ let algo = 'sha256'
+ let hex = providedHex
+ if (providedHex.includes('=')) {
+ const [prefix, ...rest] = providedHex.split('=')
+ hex = rest.join('=')
+ if (prefix === 'sha1') algo = 'sha1'
+ }
+ const expected = createHmac(algo, secret).update(new Uint8Array(rawBody)).digest()
+ let provided: Buffer
+ try {
+ provided = Buffer.from(hex, 'hex')
+ } catch {
+ return false
+ }
+ if (provided.length !== expected.length) return false
+ return timingSafeEqual(new Uint8Array(provided), new Uint8Array(expected))
+}
+
+/**
+ * Verifies a plain-token signature by doing a constant-time string comparison
+ * between the stored secret and the value provided in the request header.
+ * Used for GitLab-style webhooks that send the raw secret directly in a header.
+ *
+ * @param secret The webhook secret stored on the chatflow
+ * @param provided The raw value from the signature request header
+ * @returns true if the values match, false otherwise
+ */
+export function verifyPlainToken(secret: string, provided: string): boolean {
+ const secretBuf = Buffer.from(secret)
+ const providedBuf = Buffer.from(provided)
+ if (secretBuf.length !== providedBuf.length) return false
+ return timingSafeEqual(new Uint8Array(secretBuf), new Uint8Array(providedBuf))
+}
diff --git a/packages/server/src/utils/stripProtectedFields.test.ts b/packages/server/src/utils/stripProtectedFields.test.ts
index 8bcfdec93df..9e83cc72574 100644
--- a/packages/server/src/utils/stripProtectedFields.test.ts
+++ b/packages/server/src/utils/stripProtectedFields.test.ts
@@ -29,6 +29,18 @@ describe('stripProtectedFields', () => {
expect(result).toHaveProperty('credential', 'cred-uuid')
})
+ it('removes webhookSecret from input', () => {
+ const result = stripProtectedFields({ webhookSecret: 'attacker-chosen-secret', name: 'test' })
+ expect(result).not.toHaveProperty('webhookSecret')
+ expect(result).toHaveProperty('name', 'test')
+ })
+
+ it('removes webhookSecretConfigured from input', () => {
+ const result = stripProtectedFields({ webhookSecretConfigured: true, name: 'test' })
+ expect(result).not.toHaveProperty('webhookSecretConfigured')
+ expect(result).toHaveProperty('name', 'test')
+ })
+
it('removes all protected fields when all are present', () => {
const body = {
id: 'abc',
@@ -36,6 +48,8 @@ describe('stripProtectedFields', () => {
updatedDate: '2026-01-02T00:00:00.000Z',
workspaceId: '11111111-2222-3333-4444-555555555555',
organizationId: 'org-789',
+ webhookSecret: 'some-secret',
+ webhookSecretConfigured: true,
details: '{"name":"my assistant"}',
credential: 'cred-uuid',
iconSrc: null
diff --git a/packages/server/src/utils/stripProtectedFields.ts b/packages/server/src/utils/stripProtectedFields.ts
index 8fd91a1e012..043f3f645a6 100644
--- a/packages/server/src/utils/stripProtectedFields.ts
+++ b/packages/server/src/utils/stripProtectedFields.ts
@@ -2,7 +2,16 @@
* Fields that are managed exclusively by the server and must never be
* overwritten by user-supplied request bodies.
*/
-export const PROTECTED_FIELDS = ['id', 'createdDate', 'updatedDate', 'runDate', 'workspaceId', 'organizationId'] as const
+export const PROTECTED_FIELDS = [
+ 'id',
+ 'createdDate',
+ 'updatedDate',
+ 'runDate',
+ 'workspaceId',
+ 'organizationId',
+ 'webhookSecret',
+ 'webhookSecretConfigured'
+] as const
export type ProtectedField = (typeof PROTECTED_FIELDS)[number]
diff --git a/packages/ui/src/api/chatflows.js b/packages/ui/src/api/chatflows.js
index ea42c67225c..ba56a5ff0e0 100644
--- a/packages/ui/src/api/chatflows.js
+++ b/packages/ui/src/api/chatflows.js
@@ -22,6 +22,10 @@ const getHasChatflowChanged = (id, lastUpdatedDateTime) => client.get(`/chatflow
const generateAgentflow = (body) => client.post(`/agentflowv2-generator/generate`, body)
+const setWebhookSecret = (id) => client.post(`/chatflows/${id}/webhook-secret`)
+
+const clearWebhookSecret = (id) => client.delete(`/chatflows/${id}/webhook-secret`)
+
const getScheduleStatus = (id) => client.get(`/chatflows/${id}/schedule/status`)
const toggleScheduleEnabled = (id, enabled) => client.patch(`/chatflows/${id}/schedule/enabled`, { enabled })
@@ -42,6 +46,8 @@ export default {
getAllowChatflowUploads,
getHasChatflowChanged,
generateAgentflow,
+ setWebhookSecret,
+ clearWebhookSecret,
getScheduleStatus,
toggleScheduleEnabled,
getScheduleTriggerLogs,
diff --git a/packages/ui/src/api/webhooklistener.js b/packages/ui/src/api/webhooklistener.js
new file mode 100644
index 00000000000..4ae92d045dd
--- /dev/null
+++ b/packages/ui/src/api/webhooklistener.js
@@ -0,0 +1,6 @@
+import client from './client'
+
+const register = (chatflowid) => client.post(`/webhook-listener/${chatflowid}/register`)
+const unregister = (chatflowid, listenerId) => client.delete(`/webhook-listener/${chatflowid}/listener/${listenerId}`)
+
+export default { register, unregister }
diff --git a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx
index b6840f866e8..416206f0312 100644
--- a/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx
+++ b/packages/ui/src/ui-component/dialog/ViewMessagesDialog.jsx
@@ -195,7 +195,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
const [sourceDialogProps, setSourceDialogProps] = useState({})
const [hardDeleteDialogOpen, setHardDeleteDialogOpen] = useState(false)
const [hardDeleteDialogProps, setHardDeleteDialogProps] = useState({})
- const [chatTypeFilter, setChatTypeFilter] = useState(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED'])
+ const [chatTypeFilter, setChatTypeFilter] = useState(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED', 'WEBHOOK'])
const [feedbackTypeFilter, setFeedbackTypeFilter] = useState([])
const [startDate, setStartDate] = useState(new Date(new Date().setMonth(new Date().getMonth() - 1)))
const [endDate, setEndDate] = useState(new Date())
@@ -351,6 +351,8 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
return 'MCP'
} else if (chatType === 'SCHEDULED') {
return 'Scheduled'
+ } else if (chatType === 'WEBHOOK') {
+ return 'Webhook'
}
return 'API/Embed'
}
@@ -760,7 +762,7 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
return () => {
setChatLogs([])
setChatMessages([])
- setChatTypeFilter(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED'])
+ setChatTypeFilter(['INTERNAL', 'EXTERNAL', 'MCP', 'SCHEDULED', 'WEBHOOK'])
setFeedbackTypeFilter([])
setSelectedMessageIndex(0)
setSelectedChatId('')
@@ -918,6 +920,10 @@ const ViewMessagesDialog = ({ show, dialogProps, onCancel }) => {
label: 'Scheduled',
name: 'SCHEDULED'
},
+ {
+ label: 'Webhook',
+ name: 'WEBHOOK'
+ },
{
label: 'Evaluations',
name: 'EVALUATION'
diff --git a/packages/ui/src/ui-component/input/suggestionOption.js b/packages/ui/src/ui-component/input/suggestionOption.js
index fe8546a2f58..f806f37be1d 100644
--- a/packages/ui/src/ui-component/input/suggestionOption.js
+++ b/packages/ui/src/ui-component/input/suggestionOption.js
@@ -138,6 +138,7 @@ export const suggestionOptions = (
}))
const startAgentflowNode = nodes.find((node) => node.data.name === 'startAgentflow')
+ const { webhookQueryParams, webhookBodyParams, webhookHeaderParams } = startAgentflowNode?.data?.inputs ?? {}
const startInputType = startAgentflowNode?.data?.inputs?.startInputType
const scheduleInputMode = startAgentflowNode?.data?.inputs?.scheduleInputMode
const activeFormInputTypes =
@@ -155,6 +156,36 @@ export const suggestionOptions = (
}))
}
+ let webhookQueryItems = []
+ if (webhookQueryParams) {
+ webhookQueryItems = webhookQueryParams.map((input) => ({
+ id: `$webhook.query.${input.name}`,
+ mentionLabel: `$webhook.query.${input.name}`,
+ description: `Webhook Query: ${input.name}`,
+ category: 'Webhook Inputs'
+ }))
+ }
+
+ let webhookItems = []
+ if (webhookBodyParams) {
+ webhookItems = webhookBodyParams.map((input) => ({
+ id: `$webhook.body.${input.name}`,
+ mentionLabel: `$webhook.body.${input.name}`,
+ description: `Webhook Body: ${input.name}`,
+ category: 'Webhook Inputs'
+ }))
+ }
+
+ let webhookHeaderItems = []
+ if (webhookHeaderParams) {
+ webhookHeaderItems = webhookHeaderParams.map((input) => ({
+ id: `$webhook.headers.${input.name}`,
+ mentionLabel: `$webhook.headers.${input.name}`,
+ description: `Webhook Header: ${input.name}`,
+ category: 'Webhook Inputs'
+ }))
+ }
+
const nodeItems = (availableNodesForVariable || []).map((node) => {
const selectedOutputAnchor = node.data.outputAnchors?.[0]?.options?.find((ancr) => ancr.name === node.data.outputs['output'])
@@ -169,7 +200,16 @@ export const suggestionOptions = (
}
})
- const allItems = [...defaultItems, ...formItems, ...nodeItems, ...stateItems, ...variableItems]
+ const allItems = [
+ ...defaultItems,
+ ...formItems,
+ ...webhookQueryItems,
+ ...webhookItems,
+ ...webhookHeaderItems,
+ ...nodeItems,
+ ...stateItems,
+ ...variableItems
+ ]
return allItems.filter(
(item) => item.mentionLabel.toLowerCase().includes(query.toLowerCase()) || item.id.toLowerCase().includes(query.toLowerCase())
diff --git a/packages/ui/src/utils/genericHelper.js b/packages/ui/src/utils/genericHelper.js
index d9f464828a4..c077d3bceae 100644
--- a/packages/ui/src/utils/genericHelper.js
+++ b/packages/ui/src/utils/genericHelper.js
@@ -347,6 +347,14 @@ export const updateOutdatedNodeData = (newComponentNodeData, existingComponentNo
initNewComponentNodeData.outputAnchors[0].options = newOptions
}
+ // Recompute show/hide visibility against the merged inputs so conditional fields
+ if (initNewComponentNodeData.inputParams) {
+ initNewComponentNodeData.inputParams = showHideInputParams(initNewComponentNodeData)
+ }
+ if (initNewComponentNodeData.inputAnchors) {
+ initNewComponentNodeData.inputAnchors = showHideInputAnchors(initNewComponentNodeData)
+ }
+
return initNewComponentNodeData
}
@@ -1309,6 +1317,17 @@ export const showHideInputs = (nodeData, inputType, overrideParams, arrayIndex)
if (inputParam.hide) {
_showHideOperation(effectiveNodeData, inputParam, 'hide', arrayIndex)
}
+
+ // Filter individual options within dropdowns based on their own show/hide conditions
+ if (inputParam.type === 'options' && inputParam.options) {
+ inputParam.options = inputParam.options.filter((opt) => {
+ if (typeof opt === 'string' || (!opt.show && !opt.hide)) return true
+ const synthetic = { show: opt.show, hide: opt.hide, display: true }
+ if (opt.show) _showHideOperation(nodeData, synthetic, 'show', arrayIndex)
+ if (opt.hide) _showHideOperation(nodeData, synthetic, 'hide', arrayIndex)
+ return synthetic.display !== false
+ })
+ }
}
return params
diff --git a/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx b/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx
index 2ac1e38e67d..a12439d11dd 100644
--- a/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx
+++ b/packages/ui/src/views/agentexecutions/NodeExecutionDetails.jsx
@@ -917,8 +917,8 @@ export const NodeExecutionDetails = ({ data, label, status, metadata, isPublic,
))
) : data?.name === 'toolAgentflow' && data?.input ? (
- ) : data?.input?.form || data?.input?.http || data?.input?.conditions ? (
-
+ ) : data?.input?.form || data?.input?.http || data?.input?.webhook || data?.input?.conditions ? (
+
) : data?.input?.code ? (
{
const iconMap = {
chatInput: { icon: },
formInput: { icon: },
- scheduleInput: { icon: }
+ scheduleInput: { icon: },
+ webhookTrigger: { icon: }
}
const info = iconMap[inputType]
if (!info) return null
diff --git a/packages/ui/src/views/agentflowsv2/Canvas.jsx b/packages/ui/src/views/agentflowsv2/Canvas.jsx
index 0a3cb7370f6..962562c28c2 100644
--- a/packages/ui/src/views/agentflowsv2/Canvas.jsx
+++ b/packages/ui/src/views/agentflowsv2/Canvas.jsx
@@ -31,6 +31,7 @@ import ConfirmDialog from '@/ui-component/dialog/ConfirmDialog'
import EditNodeDialog from '@/views/agentflowsv2/EditNodeDialog'
import ChatPopUp from '@/views/chatmessage/ChatPopUp'
import ScheduleHistoryFAB from '@/views/schedule/ScheduleHistoryFAB'
+import WebhookListenerFAB from '@/views/webhooklistener/WebhookListenerFAB'
import ValidationPopUp from '@/views/chatmessage/ValidationPopUp'
import { flowContext } from '@/store/context/ReactFlowContext'
@@ -103,6 +104,12 @@ const AgentflowCanvas = () => {
return startNode?.data?.inputs?.startInputType === 'scheduleInput'
}, [nodes])
+ const isWebhookFlow = useMemo(() => {
+ if (!nodes || nodes.length === 0) return false
+ const startNode = nodes.find((n) => n.data?.name === 'startAgentflow')
+ return startNode?.data?.inputs?.startInputType === 'webhookTrigger'
+ }, [nodes])
+
const [selectedNode, setSelectedNode] = useState(null)
const [isSyncNodesButtonEnabled, setIsSyncNodesButtonEnabled] = useState(false)
const [editNodeDialogOpen, setEditNodeDialogOpen] = useState(false)
@@ -805,6 +812,8 @@ const AgentflowCanvas = () => {
)}
{isScheduleFlow ? (
+ ) : isWebhookFlow ? (
+
) : (
)}
diff --git a/packages/ui/src/views/canvas/NodeInputHandler.jsx b/packages/ui/src/views/canvas/NodeInputHandler.jsx
index 24b486e29d3..91ee92492bb 100644
--- a/packages/ui/src/views/canvas/NodeInputHandler.jsx
+++ b/packages/ui/src/views/canvas/NodeInputHandler.jsx
@@ -1,6 +1,7 @@
import PropTypes from 'prop-types'
import { Handle, Position, useUpdateNodeInternals } from 'reactflow'
import { useEffect, useRef, useState, useContext } from 'react'
+import { useParams } from 'react-router-dom'
import { useSelector, useDispatch } from 'react-redux'
import { cloneDeep } from 'lodash'
import showdown from 'showdown'
@@ -19,12 +20,24 @@ import {
Dialog,
DialogTitle,
DialogContent,
- DialogActions
+ DialogActions,
+ Alert
} from '@mui/material'
-import { useGridApiContext } from '@mui/x-data-grid'
import IconAutoFixHigh from '@mui/icons-material/AutoFixHigh'
+import InputAdornment from '@mui/material/InputAdornment'
import { tooltipClasses } from '@mui/material/Tooltip'
-import { IconWand, IconVariable, IconArrowsMaximize, IconEdit, IconAlertTriangle, IconBulb, IconRefresh, IconX } from '@tabler/icons-react'
+import { useGridApiContext } from '@mui/x-data-grid'
+import {
+ IconWand,
+ IconVariable,
+ IconArrowsMaximize,
+ IconEdit,
+ IconAlertTriangle,
+ IconBulb,
+ IconRefresh,
+ IconX,
+ IconCopy
+} from '@tabler/icons-react'
import { Tabs } from '@mui/base/Tabs'
import Autocomplete, { autocompleteClasses } from '@mui/material/Autocomplete'
@@ -68,6 +81,7 @@ import PromptGeneratorDialog from '@/ui-component/dialog/PromptGeneratorDialog'
// API
import assistantsApi from '@/api/assistants'
+import chatflowsApi from '@/api/chatflows'
import documentstoreApi from '@/api/documentstore'
// utils
@@ -82,7 +96,7 @@ import useNotifier from '@/utils/useNotifier'
// const
import { baseURL, FLOWISE_CREDENTIAL_ID } from '@/store/constant'
-import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction } from '@/store/actions'
+import { closeSnackbar as closeSnackbarAction, enqueueSnackbar as enqueueSnackbarAction, SET_CHATFLOW } from '@/store/actions'
const EDITABLE_OPTIONS = ['selectedTool', 'selectedAssistant']
@@ -125,6 +139,10 @@ const NodeInputHandler = ({
onHideNodeInfoDialog,
onCustomDataChange
}) => {
+ const { id: chatflowIdFromParams } = useParams()
+ const canvasChatflow = useSelector((state) => state.canvas.chatflow)
+ const chatflowId = chatflowIdFromParams || canvasChatflow?.id
+
const theme = useTheme()
const customization = useSelector((state) => state.customization)
const ref = useRef(null)
@@ -156,6 +174,46 @@ const NodeInputHandler = ({
const [isNvidiaNIMDialogOpen, setIsNvidiaNIMDialogOpen] = useState(false)
const [tabValue, setTabValue] = useState(0)
+ // Webhook secret — holds plaintext only for the current session (generate/regenerate response).
+ // Cleared on page reload; the configured state comes from canvasChatflow.webhookSecretConfigured.
+ const [webhookSecretPlaintext, setWebhookSecretPlaintext] = useState(null)
+
+ const handleSetWebhookSecret = async () => {
+ if (!chatflowId) return
+ try {
+ const resp = await chatflowsApi.setWebhookSecret(chatflowId)
+ setWebhookSecretPlaintext(resp.data.webhookSecret)
+ dispatch({ type: SET_CHATFLOW, chatflow: { ...canvasChatflow, webhookSecretConfigured: true } })
+ enqueueSnackbar({
+ message: 'Webhook secret generated.',
+ options: { key: new Date().getTime() + Math.random(), variant: 'success' }
+ })
+ } catch (error) {
+ enqueueSnackbar({
+ message: error?.response?.data?.message || 'Failed to generate webhook secret.',
+ options: { key: new Date().getTime() + Math.random(), variant: 'error' }
+ })
+ }
+ }
+
+ const handleClearWebhookSecret = async () => {
+ if (!chatflowId) return
+ try {
+ await chatflowsApi.clearWebhookSecret(chatflowId)
+ setWebhookSecretPlaintext(null)
+ dispatch({ type: SET_CHATFLOW, chatflow: { ...canvasChatflow, webhookSecretConfigured: false } })
+ enqueueSnackbar({
+ message: 'Webhook secret removed.',
+ options: { key: new Date().getTime() + Math.random(), variant: 'success' }
+ })
+ } catch (error) {
+ enqueueSnackbar({
+ message: error?.response?.data?.message || 'Failed to remove webhook secret.',
+ options: { key: new Date().getTime() + Math.random(), variant: 'error' }
+ })
+ }
+ }
+
const [modelSelectionDialogOpen, setModelSelectionDialogOpen] = useState(false)
const [availableChatModels, setAvailableChatModels] = useState([])
const [availableChatModelsOptions, setAvailableChatModelsOptions] = useState([])
@@ -781,6 +839,10 @@ const NodeInputHandler = ({
updateNodeInternals(data.id)
}, [data.id, position, updateNodeInternals])
+ const webhookMethod = data.inputs?.webhookMethod ?? 'POST'
+ const webhookUrlBase = chatflowId ? `${baseURL}/api/v1/webhook/${chatflowId}` : null
+ const webhookUrl = webhookUrlBase ? `${webhookMethod} ${webhookUrlBase}` : 'Save the flow first to generate the webhook URL'
+
return (
{inputAnchor && (
@@ -1081,7 +1143,144 @@ const NodeInputHandler = ({
>
)}
- {(inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') &&
+ {inputParam.name === 'webhookURL' && (
+
+
+ {
+ navigator.clipboard.writeText(webhookUrlBase).then(
+ () =>
+ enqueueSnackbar({
+ message: 'URL copied!',
+ options: {
+ key: new Date().getTime() + Math.random(),
+ variant: 'success'
+ }
+ }),
+ () =>
+ enqueueSnackbar({
+ message: 'Failed to copy URL.',
+ options: { key: new Date().getTime() + Math.random(), variant: 'error' }
+ })
+ )
+ }}
+ >
+ {/* Match the Start node's accent green (#7EE787) so the copy
+ action reads as a "primary" action on this node. */}
+
+
+
+
+ ) : undefined
+ }}
+ />
+ )}
+
+ {inputParam.name === 'webhookSecret' && (
+
+ {!canvasChatflow?.webhookSecretConfigured && !webhookSecretPlaintext ? (
+ // Not configured
+
+
+ Generate a secret below — without one, every incoming webhook request will be rejected.
+
+
+
+ No secret configured
+
+ {chatflowId && (
+
+ Generate Secret
+
+ )}
+
+
+ ) : (
+ // Configured — show masked or plaintext field with actions
+
+ {webhookSecretPlaintext && (
+
+ {
+ navigator.clipboard.writeText(webhookSecretPlaintext).then(
+ () =>
+ enqueueSnackbar({
+ message: 'Secret copied!',
+ options: {
+ key: new Date().getTime() + Math.random(),
+ variant: 'success'
+ }
+ }),
+ () =>
+ enqueueSnackbar({
+ message: 'Failed to copy secret.',
+ options: {
+ key: new Date().getTime() + Math.random(),
+ variant: 'error'
+ }
+ })
+ )
+ }}
+ >
+
+
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ )
+ }}
+ />
+ )}
+
+ )}
+
+ {inputParam.name !== 'webhookURL' &&
+ inputParam.name !== 'webhookSecret' &&
+ (inputParam.type === 'string' || inputParam.type === 'password' || inputParam.type === 'number') &&
(inputParam?.acceptVariable &&
(window.location.href.includes('v2/agentcanvas') || window.location.href.includes('v2/marketplace')) ? (
{
+ if (tone === 'success') return theme.palette.success.main
+ if (tone === 'warning') return theme.palette.warning.main
+ if (tone === 'error') return theme.palette.error.main
+ if (tone === 'info') return theme.palette.info.main
+ return theme.palette.grey[500]
+}
+
+// ─── Small atoms ──────────────────────────────────────────────────────────────
+const Caption = ({ children }) => (
+
+ {children}
+
+)
+Caption.propTypes = { children: PropTypes.node }
+
+const StatusPill = ({ status }) => {
+ const theme = useTheme()
+ const meta = STATUS_META[status] ?? STATUS_META.idle
+ const color = TONE_COLOR(theme, meta.tone)
+ const animated = status === 'listening' || status === 'running'
+ return (
+
+
+ {meta.label}
+
+ )
+}
+StatusPill.propTypes = { status: PropTypes.string.isRequired }
+
+const SonarIdle = () => {
+ const theme = useTheme()
+ const accent = theme.palette.success.main
+ return (
+
+ {[0, 0.5, 1].map((delay, i) => (
+
+ ))}
+
+
+
+
+ )
+}
+
+// ─── Endpoint block ───────────────────────────────────────────────────────────
+
+const EndpointBlock = ({ method, url, isDark, onCopy }) => {
+ const theme = useTheme()
+ const [showCurl, setShowCurl] = useState(false)
+ const [copied, setCopied] = useState(false)
+ const [copiedCurl, setCopiedCurl] = useState(false)
+ const iconColor = isDark ? 'common.white' : 'text.primary'
+
+ const curl = useMemo(
+ () => `curl -X ${method} '${url}' \\\n -H 'Content-Type: application/json' \\\n -d '{ "question": "Hello from cURL" }'`,
+ [method, url]
+ )
+
+ const copy = (text, setter) => {
+ if (!text) return
+ navigator.clipboard.writeText(text).then(() => {
+ setter(true)
+ setTimeout(() => setter(false), 1500)
+ })
+ if (onCopy) onCopy()
+ }
+
+ return (
+
+
+
+
+ {url}
+
+
+ copy(url, setCopied)} sx={{ p: 0.5, color: iconColor }}>
+ {copied ? : }
+
+
+
+
+
+ setShowCurl((v) => !v)}
+ startIcon={
+
+ }
+ sx={{
+ textTransform: 'none',
+ color: isDark ? alpha('#fff', 0.75) : 'text.secondary',
+ fontSize: 12,
+ py: 0.25,
+ '&:hover': { bgcolor: 'transparent', color: isDark ? 'common.white' : 'text.primary' }
+ }}
+ >
+ cURL example
+
+
+
+ {curl}
+
+ copy(curl, setCopiedCurl)}
+ sx={{ position: 'absolute', top: 6, right: 6, p: 0.5, color: iconColor }}
+ >
+ {copiedCurl ? : }
+
+
+
+
+
+
+ )
+}
+EndpointBlock.propTypes = {
+ method: PropTypes.string.isRequired,
+ url: PropTypes.string.isRequired,
+ isDark: PropTypes.bool,
+ onCopy: PropTypes.func
+}
+
+// ─── Drawer ───────────────────────────────────────────────────────────────────
+
+const WebhookListenerDrawer = ({ open, chatflowid, onClose, onStatusChange }) => {
+ const theme = useTheme()
+ const customization = useSelector((state) => state.customization)
+ const isDark = customization?.isDarkMode
+ const { onAgentflowNodeStatusUpdate, clearAgentflowNodeStatus } = useContext(flowContext)
+
+ // ── State machine: connecting → listening → running → done|stopped|error → listening (auto-reset)
+ const [status, setStatus] = useState('idle')
+ // Raw engine status from agentFlowEvent — passed to AgentExecutedDataCard for proper icon/state
+ // (FINISHED / INPROGRESS / STOPPED / TERMINATED / ERROR / TIMEOUT)
+ const [flowStatus, setFlowStatus] = useState(null)
+ const [executionChatId, setExecutionChatId] = useState(null)
+ const [finalMessage, setFinalMessage] = useState('')
+ const [errorMessage, setErrorMessage] = useState(null)
+ const [executedData, setExecutedData] = useState(null)
+ const [startedAt, setStartedAt] = useState(null)
+ const [finishedAt, setFinishedAt] = useState(null)
+ const [width, setWidth] = useState(DEFAULT_W)
+ const [maximized, setMaximized] = useState(false)
+
+ // Refs for lifecycle objects (the EventSource and abort controller)
+ const abortRef = useRef(null)
+ const listenerIdRef = useRef(null)
+
+ // Keep parent FAB pulse in sync with internal status
+ useEffect(() => {
+ if (onStatusChange) onStatusChange(status)
+ }, [status, onStatusChange])
+
+ // ── Webhook URL (mirrors NodeInputHandler.jsx:843 derivation)
+ const method = useMemo(() => {
+ // Try to look up the webhookMethod from the loaded canvas; fall back to POST
+ // This is best-effort — the user can always override by sending whatever the Start node accepts.
+ return 'POST'
+ }, [])
+ const webhookUrl = chatflowid ? `${baseURL}/api/v1/webhook/${chatflowid}` : ''
+
+ // ── Reset visible run state but keep the connection open and listening
+ const resetRun = useCallback(() => {
+ setFinalMessage('')
+ setErrorMessage(null)
+ setExecutedData(null)
+ setFlowStatus(null)
+ setExecutionChatId(null)
+ setStartedAt(null)
+ setFinishedAt(null)
+ clearAgentflowNodeStatus?.()
+ }, [clearAgentflowNodeStatus])
+
+ // ── Forward per-node status updates to the canvas only — the drawer renders the trace
+ // through AgentExecutedDataCard using the engine's executedData stream.
+ const applyNodeStatus = useCallback(
+ (data) => {
+ if (!data || !data.nodeId) return
+ try {
+ onAgentflowNodeStatusUpdate?.(data)
+ } catch {
+ /* canvas might not be mounted yet — ignore */
+ }
+ },
+ [onAgentflowNodeStatusUpdate]
+ )
+
+ // ── Open the SSE connection (call after register())
+ const openStream = useCallback(
+ async (id) => {
+ const ctrl = new AbortController()
+ abortRef.current = ctrl
+
+ try {
+ await fetchEventSource(`${baseURL}/api/v1/webhook-listener/${chatflowid}/stream/${id}`, {
+ openWhenHidden: true,
+ signal: ctrl.signal,
+ headers: { 'x-request-from': 'internal' },
+ async onopen() {
+ // Server sends `listenerReady` as the first event — flip to listening there
+ },
+ async onmessage(ev) {
+ if (!ev.data) return
+ let payload
+ try {
+ payload = JSON.parse(ev.data)
+ } catch {
+ return
+ }
+ switch (payload.event) {
+ case 'listenerReady':
+ setStatus('listening')
+ break
+ case 'metadata':
+ if (payload.data?.chatId) setExecutionChatId(payload.data.chatId)
+ break
+ case 'agentFlowEvent': {
+ const v = payload.data
+ setFlowStatus(v)
+ if (v === 'INPROGRESS') {
+ resetRun()
+ setFlowStatus('INPROGRESS')
+ setStartedAt(Date.now())
+ setStatus('running')
+ } else if (v === 'FINISHED') {
+ setFinishedAt(Date.now())
+ setStatus('done')
+ } else if (v === 'STOPPED' || v === 'TERMINATED') {
+ // Human-input pause — the flow is paused, awaiting input. Not a failure.
+ setFinishedAt(Date.now())
+ setStatus('stopped')
+ } else if (v === 'ERROR' || v === 'TIMEOUT') {
+ setFinishedAt(Date.now())
+ setStatus('error')
+ }
+ break
+ }
+ case 'nextAgentFlow':
+ applyNodeStatus(payload.data)
+ break
+ case 'agentFlowExecutedData':
+ setExecutedData(payload.data)
+ // Pull the assistant text out of the last node's output, if present
+ if (Array.isArray(payload.data) && payload.data.length) {
+ const last = payload.data[payload.data.length - 1]
+ const text = last?.data?.output?.content ?? last?.data?.output?.text
+ if (typeof text === 'string') setFinalMessage(text)
+ }
+ break
+ case 'token':
+ if (typeof payload.data === 'string') setFinalMessage((m) => m + payload.data)
+ break
+ case 'error':
+ setErrorMessage(typeof payload.data === 'string' ? payload.data : 'Execution error')
+ setStatus('error')
+ break
+ case 'executionEnd':
+ // Mirror finalize — promote to terminal state if we missed agentFlowEvent
+ setStatus((s) => (s === 'running' ? 'done' : s))
+ if (!finishedAt) setFinishedAt(Date.now())
+ break
+ case 'end':
+ // Source SSE closed — we ignore on the listener side; the listener stays open
+ break
+ default:
+ break
+ }
+ },
+ async onerror(err) {
+ // Surface as 'error' but let fetch-event-source retry connect transparently
+ // unless the user closed the drawer (signal aborted).
+ if (ctrl.signal.aborted) throw err
+ }
+ })
+ } catch (err) {
+ if (!ctrl.signal.aborted) {
+ setErrorMessage(err?.message || 'Listener disconnected')
+ setStatus('error')
+ }
+ }
+ },
+ [chatflowid, applyNodeStatus, resetRun, finishedAt]
+ )
+
+ // ── Lifecycle: register listener on open, tear down on close
+ useEffect(() => {
+ if (!open || !chatflowid) return
+
+ let cancelled = false
+ setStatus('connecting')
+ ;(async () => {
+ try {
+ const resp = await webhookListenerApi.register(chatflowid)
+ const id = resp?.data?.listenerId
+ if (cancelled || !id) return
+ listenerIdRef.current = id
+ openStream(id)
+ } catch (err) {
+ if (cancelled) return
+ setErrorMessage(err?.response?.data?.message || err?.message || 'Failed to register listener')
+ setStatus('error')
+ }
+ })()
+
+ return () => {
+ cancelled = true
+ abortRef.current?.abort()
+ const id = listenerIdRef.current
+ if (id) {
+ webhookListenerApi.unregister(chatflowid, id).catch(() => {})
+ listenerIdRef.current = null
+ }
+ setStatus('idle')
+ resetRun()
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [open, chatflowid])
+
+ // ── Drag-to-resize (left edge)
+ const onMouseMove = useCallback((e) => {
+ const newWidth = document.body.offsetWidth - e.clientX
+ if (newWidth >= MIN_W && newWidth <= MAX_W) setWidth(newWidth)
+ }, [])
+ const onMouseUp = useCallback(() => {
+ document.removeEventListener('mousemove', onMouseMove)
+ document.removeEventListener('mouseup', onMouseUp)
+ document.body.style.userSelect = ''
+ document.body.style.cursor = ''
+ }, [onMouseMove])
+ const onMouseDown = useCallback(() => {
+ document.body.style.userSelect = 'none'
+ document.body.style.cursor = 'ew-resize'
+ document.addEventListener('mousemove', onMouseMove)
+ document.addEventListener('mouseup', onMouseUp)
+ }, [onMouseMove, onMouseUp])
+
+ const elapsed = useMemo(() => {
+ if (!startedAt) return null
+ const end = finishedAt ?? Date.now()
+ const ms = end - startedAt
+ if (ms < 1000) return `${ms}ms`
+ return `${(ms / 1000).toFixed(ms >= 10000 ? 0 : 2)}s`
+ }, [startedAt, finishedAt])
+
+ const drawerWidth = maximized ? Math.min(720, MAX_W) : width
+
+ return (
+
+ {/* Resize handle */}
+
+ &': { opacity: 1 }
+ }}
+ />
+
+
+ {/* ─── Header ───────────────────────────────────────────────── */}
+
+
+
+
+
+
+
+ Webhook Listener
+
+ Live observatory
+
+
+ setMaximized((v) => !v)}
+ sx={{ color: isDark ? 'common.white' : 'text.primary' }}
+ >
+ {maximized ? : }
+
+
+
+
+
+
+
+
+
+ {elapsed && {elapsed} }
+ {executionChatId && (
+
+
+ {executionChatId.slice(0, 8)}…
+
+
+ )}
+
+
+
+ {/* ─── Body ─────────────────────────────────────────────────── */}
+
+ {/* Endpoint */}
+
+ Endpoint
+
+
+
+
+
+ {/* Process flow — same expandable tree the chat panel uses, with per-node JSON drilldown */}
+
+
+ Process flow
+ {status === 'running' && (
+ streaming…
+ )}
+
+
+ {executedData && Array.isArray(executedData) && executedData.length > 0 ? (
+
+ ) : status === 'listening' ? (
+
+
+
+ Waiting for an incoming webhook request…
+
+
+ Send a {method} to the endpoint above to trigger the flow.
+
+
+ ) : status === 'connecting' ? (
+
+
+ Opening event stream…
+
+ ) : status === 'running' ? (
+
+
+ Flow started — first node executing…
+
+ ) : status === 'error' ? (
+
+ {errorMessage || 'Listener error'}
+
+ ) : (
+ —
+ )}
+
+
+ {/* Final response */}
+ {(finalMessage || ((status === 'done' || status === 'stopped') && executedData)) && (
+ <>
+
+
+ Response
+
+ {finalMessage ? (
+ {finalMessage}
+ ) : (
+
+ Flow completed without a text response.
+
+ )}
+
+
+ >
+ )}
+
+ {/* Error band when status is error but we already have some context */}
+ {status === 'error' && executedData && errorMessage && (
+ <>
+
+
+ Error
+
+ {errorMessage}
+
+
+ >
+ )}
+
+
+ {/* ─── Footer ───────────────────────────────────────────────── */}
+
+ }
+ sx={{
+ textTransform: 'none',
+ fontSize: 12,
+ color: isDark ? alpha('#fff', 0.75) : 'text.secondary',
+ '&:hover': { color: isDark ? 'common.white' : 'text.primary', bgcolor: 'transparent' }
+ }}
+ >
+ Reset trace
+
+
+
+ )
+}
+
+WebhookListenerDrawer.propTypes = {
+ open: PropTypes.bool.isRequired,
+ chatflowid: PropTypes.string.isRequired,
+ onClose: PropTypes.func.isRequired,
+ onStatusChange: PropTypes.func
+}
+
+export default WebhookListenerDrawer
diff --git a/packages/ui/src/views/webhooklistener/WebhookListenerFAB.jsx b/packages/ui/src/views/webhooklistener/WebhookListenerFAB.jsx
new file mode 100644
index 00000000000..abec70abafa
--- /dev/null
+++ b/packages/ui/src/views/webhooklistener/WebhookListenerFAB.jsx
@@ -0,0 +1,88 @@
+import { useState } from 'react'
+import PropTypes from 'prop-types'
+import { Badge, Tooltip } from '@mui/material'
+import { keyframes } from '@mui/system'
+import { IconWebhook } from '@tabler/icons-react'
+
+import { StyledFab } from '@/ui-component/button/StyledFab'
+import WebhookListenerDrawer from './WebhookListenerDrawer'
+
+// Two pulses: a slow ambient one for "listening" (waiting), a fast one for "running" (in-flight)
+const pulseSlow = keyframes`
+ 0%, 100% { transform: scale(1); opacity: 1; }
+ 50% { transform: scale(1.5); opacity: 0.35; }
+`
+const pulseFast = keyframes`
+ 0%, 100% { transform: scale(1); opacity: 1; }
+ 50% { transform: scale(1.7); opacity: 0.2; }
+`
+
+const STATUS_COLOR = {
+ idle: 'grey.500',
+ connecting: 'info.main',
+ listening: 'success.main',
+ running: 'warning.main',
+ done: 'success.dark',
+ stopped: 'info.main',
+ error: 'error.main'
+}
+
+const WebhookListenerFAB = ({ chatflowid, onOpenChange }) => {
+ const [open, setOpen] = useState(false)
+ const [status, setStatus] = useState('idle')
+
+ const handleToggle = () => {
+ const next = !open
+ setOpen(next)
+ if (onOpenChange) onOpenChange(next)
+ }
+
+ const dotShouldPulse = status === 'listening' || status === 'running'
+
+ return (
+ <>
+
+ {
+ const token = STATUS_COLOR[status] ?? 'grey.500'
+ const [k, v] = token.split('.')
+ return theme.palette[k]?.[v] ?? theme.palette.grey[500]
+ },
+ boxShadow: '0 0 0 2px var(--mui-palette-background-default)',
+ animation: dotShouldPulse ? `${status === 'running' ? pulseFast : pulseSlow} 1.4s ease-in-out infinite` : 'none'
+ }
+ }}
+ >
+
+
+
+
+
+
+ {
+ setOpen(false)
+ if (onOpenChange) onOpenChange(false)
+ }}
+ onStatusChange={setStatus}
+ />
+ >
+ )
+}
+
+WebhookListenerFAB.propTypes = {
+ chatflowid: PropTypes.string.isRequired,
+ onOpenChange: PropTypes.func
+}
+
+export default WebhookListenerFAB