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.

Connect your n8n agent to Contro1

A n8n agent runs in your own code, so it connects with an Agent Credential: a key bound to one agent, so every call is attributed to it and nothing in a request can change which agent it is.

  • 1. Register the agent: contro1 init --name "<name>" --framework n8n, and finish the setup link it prints (purpose and owner).
  • 2. Connect the application account under Apps, if it is not connected yet.
  • 3. Give the agent the Actions it needs under Access. It starts with none.
  • 4. Create an Agent Credential for it (Settings, API keys) and store it as CONTRO1_API_KEY in your secret manager.
  • 5. Call Actions from your tools as below. The same credential creates approval requests for work your own code does.

Connect an agent: every path, in full

Run an application Action from a n8n tool

Contro1 holds the account and makes the call, so the record is what Contro1 observed. The tool returns what the Action produced; when a person has to approve first, it waits and then returns the result. It never re-submits: a retry could send a second email.

Before writing the input, read the exact input_schema with get_action_contract, or from the Action on the Access page.

http-request-node.json
{
  "method": "POST",
  "url": "https://api.contro1.com/api/centcom/v1/actions/invoke",
  "authentication": "genericCredentialType",
  "genericAuthType": "httpHeaderAuth",
  "sendHeaders": true,
  "headerParameters": { "parameters": [
    { "name": "Idempotency-Key", "value": "={{ $execution.id }}-{{ $runIndex }}" }
  ] },
  "sendBody": true,
  "specifyBody": "json",
  "jsonBody": "{ \"action_id\": \"gmail.message.list\", \"input\": { \"max_results\": 5 }, \"authority_mode\": \"agent_principal\", \"account_mode\": \"shared\" }"
}
// Header Auth credential: name "Authorization", value "Bearer <Agent Credential>".
// A 202 response means a person decides: poll GET /api/centcom/v1/actions/{invocation_id}
// with a Wait node until "state" is terminal. The result is in "result".

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}

Send context the reviewer can trust

A request that only says "Approve this tool call?" forces the reviewer to rubber-stamp. Send three things with every gated call: the exact tool input your gate intercepted (machine-observed fact), the agent's own justification (make reason a required parameter of the risky tool, so the model produces it at decision time), and the trigger - the user message or event that started the run.

Keep the two kinds apart in context: facts your code observed versus text the model wrote. Agent-written justification is agent-reported evidence: it helps the reviewer decide, but it must never change routing, risk_level, or approval policy, because a prompt-injected agent writes very persuasive reasons. If a high-risk request arrives without this context, fail closed and reject it instead of asking a human to guess.

Reviewer context and trust rules

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

Send full agent traceability

Beyond the approval call, attach a run trace, the tools you invoked, and the context you retrieved. Each field is optional; add what you have. Identity always comes from your credential. When a named part of a multi-agent system made the call, say which with the Contro1-Sub-Agent header (as_sub_agent in the SDKs): Contro1 registers it under this agent, and it never has more authority than this agent.

  • trace_id / parent_trace_id - link one run (and sub-agent runs) into a single trace.
  • tool_calls[] - what the agent tried to do, so reviewers see the actions. A hook can report every tool call for you.
  • retrieved_context[] - the data the decision was based on (RAG provenance).
  • Then export a signed evidence packet from GET /requests/:id/evidence.
Send full traceability
// POST /api/centcom/v1/requests - add these fields to the body you already send.
// Optional header for a named part of this agent: Contro1-Sub-Agent: billing
{
  "request_type": "approval",
  "title": "Refund $4,200 to customer 8831",
  "source": { "integration": "n8n" },
  "trace_id": "trc_<run id>",
  "tool_calls": [{ "name": "lookup_order", "outcome": "success" }],
  "retrieved_context": [{ "source": "policy:refunds", "uri": "kb://policy/refunds" }],
  "continuation": { "mode": "decision" }
}

Agent identity, traceability & signed evidence

Make the approval the gate, not a suggestion

The signed webhook is cryptographic proof of a human decision. Verify it inside the system that executes the action - not inside the agent. Any tool that must never run without human sign-off (payments, deploys, data deletion) should refuse to act without a verified approval; that way no agent, including shadow agents nobody registered, can trigger it by skipping Contro1.

  • Verify the signature and reject timestamps older than 5 minutes (replay protection; the timestamp marks callback delivery, not request creation, so long SLAs are unaffected).
  • Bind the approval to the exact action parameters via metadata / correlation_id - never treat "an approval arrived" as permission for a different action.
  • Execute each request_id exactly once (idempotency on your side).
  • When in doubt, confirm state directly with GET /v1/requests/:id using a read-only API key.

Full guardrail pattern with code: Webhooks

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.

Related resources

Prompt guardrails vs runtime control

Prompt rules and runtime control solve different problems. Here is how they differ, where each one breaks, and why production systems need both.