Skip to main content

Multi Webhook Wait Node

The Multi Webhook Wait node pauses a workflow execution and waits for multiple webhook callbacks before resuming. It enables parallel approval workflows, multi-actor sign-offs, and fan-in patterns where you need N independent confirmations before proceeding.

How It Works

Workflow starts


┌───────────────────────────────────┐
│ Create actions / notify actors │ (one per expected callback)
└───────────────────┬───────────────┘


┌───────────────────────────────────┐
│ Multi Webhook Wait Node │
│ • Registers expected callbacks │
│ • Generates callback URLs │
│ • Puts execution to sleep │
└───────────────────┬───────────────┘

External actors POST to their callback URLs
(each URL has a unique query param identifying the actor)


┌───────────────────────────────────┐
│ When ALL callbacks received: │
│ • Execution resumes │
│ • Output contains all call data │
└───────────────────────────────────┘

Key Features

  • Parallel wait: Waits for 2, 5, or 100 callbacks — any number
  • Crash-safe: State is persisted in PostgreSQL, survives n8n restarts
  • Idempotent: Duplicate callbacks are safely ignored
  • Dynamic: Expected calls can be defined statically or generated from previous node output
  • Timeout support: Optional maximum wait time before auto-resuming
  • Two operations: Wait (register + pause) and Clear (report partial completion + delete DB state) — see Handling Timeouts
  • Notify support: Optionally POSTs callback URLs to an external system when the wait begins
  • No credentials required: Uses environment variables (N8N_BASE_URL, INTERNAL_AUTH_TOKEN) directly

Configuration

Operation

ValueBehavior
Wait (default)Registers expected callbacks and pauses the workflow until they all arrive (or it times out)
ClearReports the current completion status for $execution.id (how many callbacks completed vs. are still pending) and deletes its DB entries

Use Clear downstream of a Wait node's timeout branch (e.g. after the limitWaitTime timeout resumes the execution) to get a partial-execution summary and clean up the DB row that the timed-out wait left behind. See Handling Timeouts below.

Mode Toggle: Define Expected Calls Statically

ValueBehavior
ON (default)Define each expected callback manually using the fixed collection UI
OFFProvide a JSON array (supports expressions from previous nodes)

Static Mode — Expected Calls

Add one entry per expected callback:

FieldDescriptionExample
Query ParameterThe URL query param name that identifies this callbackactor
Query ValueThe unique value for this specific callbackalice

Dynamic Mode — Expected Calls (Dynamic)

A JSON array where each object has queryParam and queryValue:

[
{ "queryParam": "actor", "queryValue": "alice" },
{ "queryParam": "actor", "queryValue": "bob" },
{ "queryParam": "actor", "queryValue": "charlie" }
]

Expression example (from a previous node that outputs a list of approvers):

={{ $json.approvers.map(name => ({queryParam: "actor", queryValue: name})) }}

Other Options

OptionDescription
Limit Wait TimeEnable to set a maximum wait duration
Timeout (Minutes)Minutes before the execution resumes automatically (default: 1440 = 24h)
Notify External SystemPOST the generated callback URLs to an external endpoint when the wait starts
Notify URLThe URL to POST to
Additional DataExtra JSON payload included in the notify POST body

Callback URLs

When the node starts waiting, it generates one callback URL per expected call. The format is:

http://<n8n-host>/webhook-waiting/<executionId>?signature=<token>&<queryParam>=<queryValue>

Example (for 2 actors):

http://localhost:5678/webhook-waiting/320?signature=abc123&actor=alice
http://localhost:5678/webhook-waiting/320?signature=abc123&actor=bob

External systems (or users) send a POST request to these URLs to signal their callback.

Sending a Callback

# Simple callback (no body)
curl -X POST "http://localhost:5678/webhook-waiting/320?signature=abc123&actor=alice"

# Callback with data payload
curl -X POST "http://localhost:5678/webhook-waiting/320?signature=abc123&actor=alice" \
-H "Content-Type: application/json" \
-d '{"decision": "approved", "comment": "Looks good"}'

Callback Responses

Partial (more callbacks expected):

{
"status": "accepted",
"message": "Callback received (actor=alice). Waiting for 1 more.",
"received": 1,
"expected": 2,
"pending": ["actor=bob"]
}

Complete (all callbacks received — execution resumes):

{
"status": "complete",
"message": "All callbacks received (2/2). Execution resumed.",
"expected": 2,
"received": 2,
"calls": {
"actor=alice": { "body": {"decision": "approved"}, "query": {...}, "receivedAt": "..." },
"actor=bob": { "body": {"decision": "approved"}, "query": {...}, "receivedAt": "..." }
}
}

Output

When all callbacks are received, the node outputs a single item:

{
"status": "complete",
"message": "All callbacks received (2/2). Execution resumed.",
"expected": 2,
"received": 2,
"calls": {
"actor=alice": {
"body": { "decision": "approved", "comment": "LGTM" },
"query": { "actor": "alice", "signature": "abc123" },
"receivedAt": "2026-07-20T16:40:00.000Z"
},
"actor=bob": {
"body": { "decision": "approved", "comment": "Ship it" },
"query": { "actor": "bob", "signature": "abc123" },
"receivedAt": "2026-07-20T16:42:00.000Z"
}
}
}

Use downstream nodes (Set, Code, IF) to process individual actor responses from $json.calls.


Handling Timeouts

Why the Wait node's own timeout output can't be trusted

When a timed-out execution resumes, n8n does not re-run the Wait node's execute() method or use whatever it returned before waiting. Internally, n8n's WorkflowExecute.handleWaitingState() (in n8n-core) does this to the waiting node on every timer-based resume:

handleWaitingState(workflow) {
if (this.runExecutionData.waitTill) {
this.runExecutionData.waitTill = undefined;
const executionStackEntry = this.runExecutionData.executionData.nodeExecutionStack[0];
executionStackEntry.node.disabled = true; // forces the node into "disabled" mode
const lastNodeExecuted = this.runExecutionData.resultData.lastNodeExecuted;
this.runExecutionData.resultData.runData[lastNodeExecuted].pop(); // discards whatever it returned pre-wait
}
}

It discards the {status: 'timeout', ...} object built in execute() and marks the node disabled, so n8n's standard disabled-node behavior kicks in: the node's output becomes its input, unchanged (executes instantly, executionTime: 0). This was confirmed by decoding a real execution's stored run data — the Wait node's output after a timer-based timeout was byte-for-byte the same items that came in from the upstream Merge node, not the status: 'timeout' payload from execute().

This does not apply to webhook-driven completion — when all callbacks arrive, webhook() supplies fresh workflowData directly, so status: 'complete' and calls reliably reach the next node.

The fix: a downstream Clear node + an IF gate

Since the Wait node's own output can't tell you it timed out, use a second Multi Webhook Wait Node configured with Operation: Clear downstream, gated by an IF node so a genuine completion isn't routed through it (Clear would report not_found, since the webhook-completion handler already deleted the DB row — see Internal Flow — and you'd lose the real calls payload sitting in the Wait node's own output):

[Multi Webhook Wait Node] (Operation: Wait, Limit Wait Time: ON)


[IF] {{ $json.status === 'complete' && $json.calls !== undefined }}

true ─┴─ false
│ │
▼ ▼
[Use [Multi Webhook Wait Node] (Operation: Clear)
$json.calls │
directly] ▼
[Process partial results]

Don't check for $json.status === 'timeout' — that value is built by execute() but, per the above, never survives a timer-based resume. Check for the positive, reliable signal (status === 'complete') instead, and treat anything else as "not complete."

The Clear node looks up DB state by $execution.id (the same execution the Wait node ran in), so no Expected Calls configuration is needed on it. It returns:

{
"status": "partial",
"message": "Cleared multi-webhook wait state for execution 320. 1/2 callbacks were received.",
"expected": 2,
"completed": 1,
"pending": 1,
"completedCallbacks": ["actor=alice"],
"pendingCallbacks": ["actor=bob"],
"calls": {
"actor=alice": { "body": { "decision": "approved" }, "query": {...}, "receivedAt": "..." }
}
}

If no entries exist for the execution (e.g. already cleaned up, or Clear was run twice), it returns { "status": "not_found", ... } instead of erroring.

Timeout timing is approximate, not exact

n8n only discovers newly-waiting executions on a 60-second polling interval (WaitTracker.startTracking()setInterval(..., 60000)), plus once at process startup. A short Limit Wait Time (e.g. 3 seconds) can sit undiscovered for up to ~60 seconds before n8n even schedules the precise timer for it, so the actual resume can lag well behind the configured amount. This is a platform characteristic shared by the native n8n-nodes-base.wait node — not something this node's code controls. For timeouts of a few minutes or more, the effect is negligible.


Example: Multi-Actor Approval with Workflow Interaction Layer

This is the primary use case — create WIL actions for multiple actors and wait for all of them to respond before continuing.

Workflow Structure

[Webhook Trigger]


[Get Form Details] (fetch form metadata from a data table)


[Edit Fields] (prepare data)

├──▶ [WIL: Create Action for Actor 1] (callbackUrl: resumeUrl + &actor=alice)

└──▶ [WIL: Create Action for Actor 2] (callbackUrl: resumeUrl + &actor=bob)


[Merge Node]


[Multi Webhook Wait Node] (expects: actor=alice, actor=bob)


[Process Results] (both actors have responded)

Step-by-Step Setup

1. Create Actions with Callback URLs

Each Workflow Interaction Layer action node sets its callbackUrl to include the actor identifier appended to the execution's resume URL:

={{ $execution.resumeUrl + '&actor=alice' }}

This tells the WIL service: "When this actor responds, POST to this URL with ?actor=alice in the query string."

2. Configure the Multi Webhook Wait Node

Dynamic mode (recommended when actors come from data):

[
{ "queryParam": "actor", "queryValue": "alice" },
{ "queryParam": "actor", "queryValue": "bob" }
]

Or with an expression that reads actor identifiers from input data:

={{ $json.actors.map(a => ({queryParam: "actor", queryValue: a.email})) }}

Static mode (for fixed, known actors):

Add two entries manually:

  • Query Parameter: actor, Query Value: alice
  • Query Parameter: actor, Query Value: bob

3. Process Results Downstream

After the wait node resumes, access individual actor responses in downstream nodes:

// In a Code node or expression:
{{ $json.calls["actor=alice"].body.decision }} // "approved"
{{ $json.calls["actor=bob"].body.comment }} // "Ship it"

Sample Workflow JSON

Below is a complete working workflow demonstrating the pattern with two WIL actions and a Multi Webhook Wait node:

{
"nodes": [
{
"parameters": {
"path": "approval-trigger",
"options": {}
},
"type": "n8n-nodes-base.webhook",
"typeVersion": 2.1,
"position": [-768, -304],
"name": "Webhook Trigger"
},
{
"parameters": {
"resource": "action",
"actorId": "alice@example.com",
"actionType": "showform",
"actionTitle": "Please Review and Approve",
"callbackUrl": "={{ $execution.resumeUrl + '&actor=alice' }}"
},
"type": "CUSTOM.workflowInteractionLayer",
"typeVersion": 1,
"position": [-96, -416],
"name": "Create Action for Alice",
"credentials": {
"workflowInteractionLayerApi": {
"id": "your-credential-id",
"name": "Your WIL Credential"
}
}
},
{
"parameters": {
"resource": "action",
"actorId": "bob@example.com",
"actionType": "showform",
"actionTitle": "Please Review and Approve",
"callbackUrl": "={{ $execution.resumeUrl + '&actor=bob' }}"
},
"type": "CUSTOM.workflowInteractionLayer",
"typeVersion": 1,
"position": [-96, -208],
"name": "Create Action for Bob",
"credentials": {
"workflowInteractionLayerApi": {
"id": "your-credential-id",
"name": "Your WIL Credential"
}
}
},
{
"parameters": {},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [144, -304],
"name": "Merge"
},
{
"parameters": {
"useStaticExpectedCalls": false,
"expectedCallsDynamic": "[\n {\"queryParam\": \"actor\", \"queryValue\": \"alice\"},\n {\"queryParam\": \"actor\", \"queryValue\": \"bob\"}\n]"
},
"type": "CUSTOM.multiWebhookWait",
"typeVersion": 1,
"position": [336, -304],
"name": "Multi Webhook Wait Node"
},
{
"parameters": {},
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [576, -304],
"name": "Process Results"
}
],
"connections": {
"Webhook Trigger": {
"main": [
[
{ "node": "Create Action for Alice", "type": "main", "index": 0 },
{ "node": "Create Action for Bob", "type": "main", "index": 0 }
]
]
},
"Create Action for Alice": {
"main": [[{ "node": "Merge", "type": "main", "index": 0 }]]
},
"Create Action for Bob": {
"main": [[{ "node": "Merge", "type": "main", "index": 1 }]]
},
"Merge": {
"main": [[{ "node": "Multi Webhook Wait Node", "type": "main", "index": 0 }]]
},
"Multi Webhook Wait Node": {
"main": [[{ "node": "Process Results", "type": "main", "index": 0 }]]
}
}
}

Example: Dynamic Approvers from a List

When the number of approvers isn't known at design time:

[Get Approvers List] → outputs: {approvers: ["alice@example.com", "bob@example.com", "charlie@example.com"]}


[Loop: Create WIL Action for each approver]
(callbackUrl: $execution.resumeUrl + '&actor=' + $json.email)


[Multi Webhook Wait Node]
expectedCallsDynamic: ={{ $json.approvers.map(email => ({queryParam: "actor", queryValue: email})) }}


[All approvers have responded — continue]

Architecture

State Persistence

Unlike the previous in-memory implementation, this node persists all state in PostgreSQL via the external-hooks service. This means:

  • State survives n8n restarts
  • Works in multi-worker/queue-mode deployments
  • No data loss if a callback arrives during a restart

Internal Flow

  1. execute() — Operation: Wait: Registers expected callbacks with the external-hooks DB, puts execution to wait
  2. webhook(): Called by n8n each time a POST hits the webhook-waiting URL:
    • Matches the incoming query params to an expected call
    • Calls the external-hooks service to mark it received (DB update)
    • If not all received → returns { webhookResponse } (n8n keeps waiting)
    • If all received → returns { workflowData } (n8n resumes execution) and the route handler deletes the DB row immediately (see multi-webhook-wait.ts)
  3. execute() — Operation: Clear: Fetches live status (GET /status/:executionId) and deletes the DB row (DELETE /cleanup/:executionId) for $execution.id. Used downstream of a timed-out Wait node — see Handling Timeouts

Database Tables

Created by Drizzle migration 0009_multi_webhook_wait.sql:

TablePurpose
multi_webhook_waitOne row per waiting execution (tracks total expected/received)
multi_webhook_wait_callOne row per expected callback (tracks received status + payload)

State is automatically cleaned up when all callbacks arrive and the execution resumes.


Environment Requirements

The node requires these environment variables (already configured in the standard Docker setup):

VariablePurpose
N8N_BASE_URLBase URL of the n8n instance (e.g. http://localhost:5678)
INTERNAL_AUTH_TOKENShared secret for internal service-to-service auth

No credentials need to be configured on the node itself.


File Structure

community-nodes/
├── nodes/
│ └── MultiWebhookWait/
│ ├── MultiWebhookWait.node.ts # Node implementation
│ └── MultiWebhookWait.node.json # Codex metadata (links to this doc)
├── icons/
│ ├── multi-webhook-wait.svg # Light mode icon
│ └── multi-webhook-wait.dark.svg # Dark mode icon

external-hooks/
├── src/
│ ├── db/
│ │ ├── schema/multi-webhook-wait.ts # Drizzle table definitions
│ │ └── repository/custom/multi-webhook-wait.ts # Data access layer
│ └── api/
│ ├── services/multi-webhook-wait.service.ts # Business logic
│ ├── routes/multi-webhook-wait.ts # API endpoints
│ └── schemas/multi-webhook-wait.ts # Zod validation
├── drizzle/
│ └── 0009_multi_webhook_wait.sql # Database migration

Troubleshooting

IssueCauseFix
404 on callback URLSending GET instead of POSTUse curl -X POST or configure the caller to POST
500 on registerDatabase tables don't existRun the Drizzle migration or rebuild Docker (docker-compose up --build)
"Cannot determine n8n base URL"N8N_BASE_URL env var missingAdd it to your .env / docker-compose
"INTERNAL_AUTH_TOKEN is not configured"Env var missingAdd INTERNAL_AUTH_TOKEN to your .env / docker-compose
Duplicate callbacks ignoredSame actor calling twiceBy design — idempotent. Only the first call counts
Wait node's output after timeout looks like its input, not {status: 'timeout', ...}n8n discards the node's pre-wait return value and disables it (pure passthrough) on every timer-based resume — this is n8n platform behavior, not a bugDon't rely on the Wait node's own output for timeout detection. Gate on $json.status === 'complete' and route the false branch to a Clear-operation node — see Handling Timeouts
Short Limit Wait Time (e.g. 3s) takes much longer to actually resumen8n's WaitTracker only polls for new waiting executions every 60 secondsExpected — see Timeout timing is approximate. Not fixable from node code
firstEntryJson response on final callOld Docker image without responseData: '' fixRebuild Docker with latest code

API Endpoints (External-Hooks Service)

These are internal endpoints used by the node. You typically don't call them directly.

MethodPathAuthPurpose
POST/rest/custom/v1/multi-webhook-wait/registerBearer (internal)Register expected callbacks
POST/rest/custom/v1/multi-webhook-wait/callback/:executionIdBearer (internal)Mark a callback as received
GET/rest/custom/v1/multi-webhook-wait/status/:executionIdBearer (internal)Check current progress (used by the Clear operation)
DELETE/rest/custom/v1/multi-webhook-wait/cleanup/:executionIdBearer (internal)Delete DB state for an execution (used by the Clear operation)

Limitations

  • POST only: Callback URLs must be called with HTTP POST (not GET). This is by design for security — prevents accidental resume via browser link previews.
  • Query param matching: Each expected call is identified by exactly one query param/value pair. Complex matching (multiple params per call) is not supported.
  • No partial resume: The workflow only resumes when ALL expected calls are received (or timeout). There is no "resume after N of M" mode.
  • No live timeout hook: n8n discards the Wait operation's pre-wait return value and disables the node on timer-based resume (pure input passthrough), so its own output can never reflect callbacks that arrived mid-wait. Use a downstream Clear-operation node, gated on $json.status === 'complete', to get an accurate partial-completion report — see Handling Timeouts.
  • Timeout timing is approximate: n8n's WaitTracker polls for newly-waiting executions every 60 seconds, so short Limit Wait Time values (seconds) can resume much later than configured. See Timeout timing is approximate.