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.
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
- After the first approval, an IF node checks
decision === "approved"and triggers a second Webhook → Slack pair for the next approver. - Store each step in the same
approvalstable with astagecolumn (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
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:
- Persisting every decision in a relational table,
- Adding a Wait + reminder to keep the flow alive, and
- 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.



