Framework guides

How to add human approvals to n8n

Use HTTP Request and Wait nodes in n8n to pause workflows, route approvals through Contro1, and branch on the result.

n8n is often the fastest path to a working approval flow because you can compose the request, wait state, and branch logic with built-in nodes.

Use the integration skill

Copy this skill link into your code agent to add n8n and Contro1 to your system.

Copy skill link

Key takeaways

  • The five-node pattern - Trigger, Set, HTTP Request, Wait, Switch - covers 90% of approval workflows.
  • An Error Trigger workflow turns any n8n execution failure into a Contro1 request for an on-call human.
  • The callback-proxy script verifies the Contro1 signature before forwarding to the Wait node's resume URL.
  • Keep execution_id in metadata so the Wait node only resumes the right execution.

When to reach for Contro1 with n8n

n8n is fantastic for ops automation but lacks a first-class "wait for a specific human to decide" node. Contro1 fills that gap - your workflow calls our API, pauses on Wait On Webhook Call, and resumes only when a verified operator decision comes back.

The typical setup is a single workflow with five nodes and a tiny callback proxy. Teams usually have it in production within an afternoon.

Installation

pip install
pip install centcom flask python-dotenv requests
.env
CENTCOM_API_KEY=your_centcom_api_key
CENTCOM_BASE_URL=https://api.contro1.com/api/centcom/v1
CENTCOM_WEBHOOK_SECRET=whsec_your_signing_secret

Basic integration

request_payload.json
{
  "type": "approval",
  "question": "Approve CRM write-back?",
  "context": "Sync 240 records from staging to production.",
  "callback_url": "https://your-callback-proxy.com/centcom/forward",
  "priority": "urgent",
  "required_role": "manager",
  "external_request_id": "n8n:crm-sync:{{$execution.id}}",
  "metadata": {
    "workflow": "crm-sync",
    "execution_id": "{{$execution.id}}",
    "resume_url": "{{$resumeWebhookUrl}}"
  }
}
n8n_callback_proxy.py
from fastapi import FastAPI, Request, HTTPException
import httpx, os
from centcom import verify_webhook

app = FastAPI()

@app.post("/centcom/forward")
async def forward(req: Request):
    body = await req.body()
    ok = verify_webhook(
        body,
        req.headers.get("x-centcom-signature", ""),
        req.headers.get("x-centcom-timestamp", ""),
        os.environ["CENTCOM_WEBHOOK_SECRET"],
    )
    if not ok:
        raise HTTPException(status_code=401)
    payload = await req.json()
    resume_url = payload["metadata"]["resume_url"]
    async with httpx.AsyncClient() as client:
        r = await client.post(resume_url, json=payload, timeout=10)
    return {"forwarded": r.status_code}

Case continuity

Set correlation_id to {{$execution.id}} so the approval request, Wait node resume, and follow-up audit records for the same n8n execution all appear in one case timeline.

Keep external_request_id unique per approval node. If the same execution has two approval points, they share correlation_id but use different idempotency keys.

case_request.json
{
  "type": "approval",
  "question": "Approve CRM write-back?",
  "callback_url": "https://your-callback-proxy.com/centcom/forward",
  "external_request_id": "n8n:{{$workflow.id}}:{{$execution.id}}:crm-write",
  "correlation_id": "{{$execution.id}}",
  "metadata": {
    "workflow_id": "{{$workflow.id}}",
    "execution_id": "{{$execution.id}}",
    "resume_url": "{{$resumeWebhookUrl}}"
  }
}

Audit records and cases reference

Logging autonomous actions

Use POST /api/centcom/v1/audit-records from an HTTP Request node when the workflow performs an allowed action and only needs durable evidence.

For actions that happen after a Wait node resumes, include in_reply_to with the request id returned by the approval step.

audit_record.json
{
  "action": "n8n.crm_sync_completed",
  "summary": "Synced approved CRM records to production",
  "source": {
    "integration": "n8n",
    "workflow_id": "{{$workflow.id}}",
    "run_id": "{{$execution.id}}"
  },
  "outcome": "success",
  "correlation_id": "{{$execution.id}}",
  "in_reply_to": { "type": "request", "id": "{{$json.request_id}}" }
}

Gate the tool before it executes

The tool function itself is the right place to require approval for irreversible actions. The first line of a destructive tool calls Contro1 and blocks until an operator decides. Nothing runs until the human says yes - no prompt engineering needed.

Place an HTTP Request node pointing to {{$env.CENTCOM_BASE_URL}}/requests, then a Wait node (On Webhook Call). Connect the Wait node's output to an IF node that branches on approved: true before the destructive action node runs.

centcom_gate_node.json
{
  "type": "approval",
  "question": "Write {{$json.record_count}} records to production CRM?",
  "context": "Workflow: {{$workflow.name}} - Execution: {{$execution.id}}",
  "callback_url": "https://your-callback-proxy.com/centcom/resume",
  "required_role": "manager",
  "external_request_id": "n8n:{{$workflow.id}}:{{$execution.id}}",
  "metadata": {
    "workflow_id": "{{$workflow.id}}",
    "execution_id": "{{$execution.id}}"
  }
}

Pause the agent on system error - orchestrator level

Create a second workflow of type "Error Trigger" that every production workflow routes to. On failure, it opens a Contro1 request describing which workflow died and where. An on-call engineer decides: retry the failed execution, mark it as skipped, or escalate.

error_trigger_payload.json
{
  "type": "approval",
  "question": "Workflow {{$json.workflow.name}} failed - retry?",
  "context": "Error: {{$json.execution.error.message}}\nNode: {{$json.execution.lastNodeExecuted}}\nExecution: {{$json.execution.id}}",
  "callback_url": "https://your-callback-proxy.com/centcom/error-retry",
  "priority": "urgent",
  "required_role": "oncall",
  "external_request_id": "n8n-err:{{$json.workflow.id}}:{{$json.execution.id}}",
  "metadata": {
    "workflow_id": "{{$json.workflow.id}}",
    "execution_id": "{{$json.execution.id}}"
  }
}

Escalate tool errors to a human

If the real work happens in a custom service called by the n8n HTTP Request node (not inside n8n itself), wrap that service call so it can escalate on its own before returning an error to n8n.

service/handler.py
import centcom
from fastapi import FastAPI, Request

app = FastAPI()

@app.post("/crm/sync")
async def sync(req: Request):
    payload = await req.json()
    try:
        return crm.sync(payload["records"])
    except crm.SchemaMismatch as exc:
        req = centcom.create_request(
            type="approval",
            question=f"CRM schema drift detected on sync {payload['execution_id']}. Proceed with partial sync?",
            context=str(exc),
            required_role="data-ops",
            external_request_id=f"crm-schema:{payload['execution_id']}",
        )
        decision = centcom.wait_for_response(req["id"], timeout=900)
        if decision["response"].get("approved"):
            return crm.sync_partial(payload["records"])
        return {"status": "skipped_by_operator"}

Prompt engineering: force the agent to pause

If an LLM node in n8n authors the content that later triggers an approval, nail down behavior in its system prompt: the model must describe the proposed action in structured output (draft + target + scope) rather than acting on it.

llm_system_prompt.md
You are the drafting step of a broader n8n automation. You NEVER send, publish, or write to external systems directly. Your only job is to output one JSON object with this schema:

{
  "action": one of ["send_email", "update_crm", "publish_post", "write_sql"],
  "target": the human-readable resource affected (customer id, post title, table name),
  "scope": a one-sentence summary of blast radius ("affects 14 records", "visible to all subscribers"),
  "draft": the exact payload that would be sent if approved
}

A downstream Contro1 approval step will show this object to a human who will approve, reject, or comment. Do not attempt to take the action yourself. Do not include markdown, prose, or explanations outside the JSON.

See our GitHub integration repo

Reference implementation: request template + callback proxy + node-by-node wiring guide.

centcom-n8n on GitHub · request_payload.json - HTTP Request template · n8n_callback_proxy.py - signed-callback forwarder

Frequently asked questions

Can I use n8n for multi-step approvals?

Yes, but model it as explicit branches with clear timeout and escalation behavior rather than one ambiguous wait state. Chain Wait nodes with Switch nodes between them.

Why do I need a callback proxy - why not point Contro1 straight at the Wait resume URL?

The resume URL is not validated by n8n, so anyone with the URL could inject a fake decision. The proxy verifies the Contro1 HMAC signature first and only then forwards to n8n.

How do I handle timeouts?

Put a Switch node after the Wait with explicit branches for approved, rejected, and timed_out. Fail closed on timeout by default.

Does this work in n8n cloud?

Yes. Host the callback proxy wherever you like (Cloud Run, Vercel, Fly.io) - it just needs a public HTTPS URL and the CENTCOM_WEBHOOK_SECRET env var.