Human‑in‑the‑Loop Workflows in n8n

Step by Step Guide to solve designing human in the loop n8n 
Step by Step Guide to solve designing human in the loop n8n


Who this is for: Developers and ops engineers who need a reliable way to pause an automated n8n flow for a manual decision without building a custom UI from scratch. We cover this in detail in the n8n Architectural Decision Making Guide.


One‑Sentence Answer (Featured Snippet)

Build a Webhook → Function → Human‑Approval (Webhook/UI) → Continue chain, store the decision in a database, and use Set and IF nodes to branch; add a Wait node with reminder and escalation to avoid stalled runs.

In production, this usually shows up when a request sits in the queue for hours waiting for a sign‑off.


Quick Diagnosis – What problem does this page solve?

If you encounter any n8n with custom api resolve them before continuing with the setup.

Your automation requires a manual sign‑off (e.g., purchase approval) but you don’t want the whole workflow to stop or to write a bespoke front‑end.

Solution in 4 steps

  • Webhook – receives the request that needs approval.
  • Notification – send an Approve/Reject button via Slack, Email or a simple UI.
  • Persist – write the human’s choice (and metadata) to a DB for auditability.
  • Branch – an IF node routes the flow to “approved” or “rejected” paths, with timeout‑driven escalation if nobody replies.

1. When to Use a Human‑in‑the‑Loop (HITL) Pattern?

If you encounter any n8n webhooks for developers resolve them before continuing with the setup.

Use‑case Why HITL is required Typical n8n nodes
Purchase order approval Legal/compliance check Webhook → Slack → IF
Content moderation Subjective judgement Webhook → UI → PostgreSQL
Incident triage Prioritisation by on‑call engineer Webhook → Email → Set
Data quality validation Humans spot anomalies that scripts miss Webhook → Function → IF
Feature‑flag rollout Controlled rollout with a manual gate Webhook → Set → HTTP Request

EEFA note: Never rely on a single person for critical compliance steps without an immutable audit trail (timestamp, user, payload).


2. Prerequisites & Environment Setup

If you encounter any n8n for internal tools vs core systems resolve them before continuing with the setup.

Item Minimum requirement How to verify
n8n version ≥ 1.2.0 (supports “Webhook Response” UI) n8n --version
Database SQLite (dev) or PostgreSQL/MySQL (prod) Test connection in **Credentials**
Notification channel Slack, Microsoft Teams, or SMTP Install respective node credentials
Execution mode Queue (for reliability) Settings → Execution Mode
SSL Valid certificate for public webhooks Use Let’s Encrypt or a reverse‑proxy

EEFA: If you run n8n behind a self‑signed cert, add it to **Trusted Certificates**; otherwise the webhook returns “Invalid SSL certificate” and the HITL flow stalls.


3. Core Workflow – From Trigger to Human Approval

Micro‑summary: The diagram below shows the end‑to‑end data flow, from the incoming request to the final approved/rejected branch.

Most teams run into this after a few weeks, not on day one – the first few approvals feel smooth, then edge‑cases appear.

Webhook Trigger
Extract Data (Function)
Notify Approver (Slack / Email)
Webhook Approval

3.1 Trigger & Data Extraction

{
  "parameters": {
    "path": "purchase-request",
    "method": "POST",
    "responseMode": "onReceived"
  },
  "name": "Webhook Trigger",
  "type": "n8n-nodes-base.webhook",
  "typeVersion": 1,
  "position": [250, 300]
}

Purpose: Accept a POST containing the purchase request payload.

// Function: Extract only the fields we need
return [{
  json: {
    requestId: $json.id,
    amount: $json.amount,
    requester: $json.user
  }
}];

Purpose: Normalise the incoming JSON so downstream nodes only see the three fields we care about.

*It’s easy to miss this step the first time you wire the flow – you’ll end up with a giant payload that later nodes can’t parse.*

3.2 Sending the Approval Prompt

{
  "parameters": {
    "url": "https://hooks.slack.com/services/XXXXX/XXXXX/XXXXX",
    "method": "POST",
    "jsonParameters": true,
    "options": { "bodyContentType": "json" },
    "bodyParametersJson": {
      "text": "*Purchase Request*\\nID: {{ $json.requestId }}\\nAmount: ${{ $json.amount }}\\nRequester: {{ $json.requester }}",
      "attachments": [{
        "fallback": "Approve or reject",
        "callback_id": "purchase_approval",
        "actions": [
          { "name": "approve", "text": "Approve", "type": "button", "value": "approved" },
          { "name": "reject", "text": "Reject", "type": "button", "value": "rejected" }
        ]
      }]
    }
  },
  "name": "Notify Approver (Slack)",
  "type": "n8n-nodes-base.httpRequest",
  "typeVersion": 1,
  "position": [750, 300]
}

Purpose: Post a message with interactive buttons to Slack (or replace with Email/Teams).

3.3 Capturing the Human Decision

{
  "parameters": {
    "path": "approval-response",
    "method": "POST",
    "responseMode": "onReceived",
    "responseCode": 200,
    "responseHeaders": [{ "name": "Content-Type", "value": "application/json" }],
    "responseBody": "{\"status\":\"received\"}"
  },
  "name": "Webhook Approval",
  "type": "n8n-nodes-base.webhook",
  "typeVersion": 1,
  "position": [250, 600]
}

Purpose: Slack posts the button click to this endpoint.

// Function: Pull decision, approver name, and timestamp
return [{
  json: {
    requestId: $json.requestId,
    decision: $json.payload.actions[0].value,
    approver: $json.payload.user.name,
    timestamp: new Date().toISOString()
  }
}];

Purpose: Transform the Slack payload into a flat record ready for storage.

3.4 Persisting the Decision

{
  "parameters": {
    "operation": "insert",
    "table": "approvals",
    "columns": ["request_id","decision","approver","timestamp"]
  },
  "name": "Save Decision",
  "type": "n8n-nodes-base.postgres",
  "typeVersion": 1,
  "position": [750, 600],
  "credentials": { "postgres": "PostgresDB" }
}

Purpose: Write an immutable audit row; the DB schema is described in Section 5.

*At this point, persisting the decision is usually faster than trying to recompute it later.*

3.5 Branching on the Result

{
  "parameters": {
    "conditions": {
      "boolean": [{
        "value1": "={{ $json.decision }}",
        "operation": "equal",
        "value2": "approved"
      }]
    }
  },
  "name": "IF Approved",
  "type": "n8n-nodes-base.if",
  "typeVersion": 1,
  "position": [1000, 600]
}

Purpose: If the decision is **approved**, the left branch fires; otherwise the right branch handles rejection.

// Left branch – mark as approved
return [{ json: { status: "Approved", requestId: $json.requestId } }];
// Right branch – mark as rejected
return [{ json: { status: "Rejected", requestId: $json.requestId } }];

4. Adding Timeout & Escalation Logic

Micro‑summary: A Wait node followed by conditional checks ensures the workflow never hangs indefinitely.

Scenario Nodes to add Key configuration
No response within 12 h **Wait** → **IF** → **Send Reminder** Wait `12h`; IF checks DB for missing decision; reminder via Slack/Email
Still silent after 24 h **Set** → **Terminate** (status *failed*) Set `escalated = true`; Terminate with error code for monitoring
Auto‑reject after timeout **IF** (decision missing) → **Set decision = rejected** → **Save Decision** Guarantees an audit row even when nobody answered

EEFA tip: Add a deadline_at column (see Section 5). After the **Wait** node finishes, compare NOW() with deadline_at to decide whether a late response is acceptable.


5. Persisting State & Auditing

Table schema (PostgreSQL)

Column Type Description
id SERIAL PK Unique row identifier
request_id TEXT Business‑level request identifier
decision TEXT `approved`, `rejected`, or `timeout`
approver TEXT Slack/Email user who acted
timestamp TIMESTAMPTZ Exact moment of decision
deadline_at TIMESTAMPTZ Configured timeout deadline
notes TEXT Optional free‑form comment

Why store in a DB?

  • Immutability – required for compliance audits.
  • Replayability – you can rebuild downstream state from the table.
  • Metrics – average approval time, missed deadlines, etc.

6. Testing & Debugging Checklist

✅ Item How to verify
Webhook URL reachable from the internet curl -X POST <url> returns **200**
Slack button payload maps to payload.actions[0].value Look at execution data of **Webhook Approval**
DB row inserted with correct request_id SELECT * FROM approvals WHERE request_id = 'XYZ';
IF node correctly branches on approved Run a test approval, watch **Mark as Approved** fire
Timeout triggers reminder Set **Wait** to 1 min, leave request idle, verify reminder message
Audit trail contains deadline_at Confirm column populated on insert
Workflow fails gracefully on DB loss Stop DB, trigger request, ensure **Terminate** fires with error

EEFA note: In production, enable **“Save Execution Data → Full”** for at least 30 days. This gives a complete replay window for forensic analysis.


7. Common Errors & Production‑Ready Fixes

Error message Likely cause Fix
`Error: Request failed with status code 400` (Slack) Payload exceeds Slack’s 3 000‑char limit Trim the message or move large data to a link (e.g., S3)
`Webhook not found` URL changed after n8n restart Use **Static URL** in Settings or store the URL in an environment variable
`duplicate key value violates unique constraint “approvals_request_id_key”` Double‑click or retry inserts same row Add a **UNIQUE** constraint on request_id; in n8n, check existence before insert
`Execution timed out` Wait node too short for a human response Increase **Execution Timeout** in Settings (default 30 min)
`Invalid SSL certificate` Self‑signed cert on webhook endpoint Add cert to **Trusted Certificates** or terminate TLS at a reverse proxy with a valid cert

8. Advanced Patterns

8.1 Sequential Multiple Approvers

  1. After the first approval, an IF node checks decision === "approved" and triggers a second Webhook → Slack pair for the next approver.
  2. Store each step in the same approvals table with a stage column (e.g., stage = 1, stage = 2).

8.2 Dynamic Forms

  • Use a **Function** node to build a JSON payload that includes extra fields based on request type.
  • Render those fields with Slack **Block Kit** or Microsoft Teams **Adaptive Cards**.

8.3 External Form Services (Typeform, Google Forms)

Replace the Slack button with a pre‑filled Typeform link. Capture the response via the **Typeform Webhook** node, then reuse the **Parse Approval** logic unchanged.


9. Deploying to Production

Step Action
Environment variables Store secrets (N8N_WEBHOOK_URL, POSTGRES_HOST, SLACK_WEBHOOK) in .env
Docker compose Use the official n8n image, mount a persistent volume for SQLite (if used), set EXECUTIONS_PROCESS=main
Scaling Run n8n behind a **Redis Queue** (EXECUTIONS_MODE=queue) and spin up multiple worker containers
Monitoring Enable Prometheus metrics (N8N_METRICS=true) and set alerts on workflow_execution_failed_total
Backup Nightly pg_dump of the approvals table → immutable object store (e.g., S3 Glacier)

EEFA reminder: After every deployment, re‑run the timeout & escalation tests. A mis‑configured **Wait** can leave requests hanging, consuming worker slots and driving up costs.


9.1 Timeout Flow Diagram

Wait 12 h
Decision found?
Yes → Continue
No → Send Reminder
Wait 12 h (total 24 h)
Escalate / Auto‑reject

10. Conclusion

A human‑in‑the‑loop workflow in n8n can be built with just a handful of nodes, yet it provides a production‑grade audit trail, timeout handling, and clear branching logic. By:

  1. Persisting every decision in a relational table,
  2. Adding a Wait + reminder to keep the flow alive, and
  3. Deploying with queue mode, SSL, and monitoring,

you get a fast, reliable automation that still respects the need for manual oversight. Deploy, monitor, and iterate on the timeout thresholds to match your organization’s SLA—your automation will stay both efficient and compliant.

Leave a Comment

Your email address will not be published. Required fields are marked *