How do you handle human approvals for AI agent workflows in n8n?

I’ve been running AI agents through n8n — mostly OpenAI/Claude nodes that trigger HTTP calls and database writes. Last week one of my agents hallucinated a parameter and hit a production API with bad data. No approval step in place, it just went through.

I tried the Wait node + Webhook approach for manual approvals, but a few things bugged me There’s no real audit trail once the execution finishes I can’t easily set conditional rules like "only require approval if the amount is over X The people approving aren’t always n8n users, so sending them into the workflow editor feels wrong.

Curious how others are handling this. Is everyone just building their own IF + Wait + Webhook chains, or has someone found a cleaner pattern?

I’ve started building something to solve this for myself (separate approval dashboard, policy rules, audit log), but I’d love to hear what’s working for others before I go too far down that road.

1 « J'aime »

Two things I’d separate before building anything: the decision (“does this call need a human?”) and the wait.

Right now both live inside the workflow (IF node + Wait). That’s why your “only if amount > X” rule is painful: it gets copy-pasted into every workflow and drifts. Pull the decision out into one place that every risky node calls first and that returns exactly one of three answers: allow / review / block. The workflow only branches on that answer. Your threshold rules live in that one spot. When finance changes X, you change it once.

On the audit trail: n8n execution data is not an audit log. It gets pruned, it’s editable by anyone with instance access, and it doesn’t record who approved. Write one row per decision to a table n8n can only INSERT into (separate DB role, no UPDATE/DELETE): action, hash of the parameters, verdict, approver identity, timestamp. That’s the record you’ll actually show an auditor or a customer. Chaining the hashes is a nice extra, but insert-only is 80% of the value.

Approvers who aren’t n8n users: native Send-and-Wait over Slack/email already handles this — the approve link is signed, they never see the editor. Just make sure the approver’s identity lands in that same audit row, and that timeout = deny, not = proceed.

Disclosure: I’m building in this space, so weigh that accordingly. Curious what others here do with the timeout case.

Hi BorkoB
For a content-writing flow, I’ve been using the Telegram node with “Send and Wait for Response” as the human approval step.

The flow is roughly like AI agent writes the content → saves it to a Google Drive document → Telegram sends a message with a link to the document for review → human reads/edits the Google Doc and approves via Telegram → workflow continues and publishes the content.

I think this pattern could also work for your use case. The approver doesn’t need to be an n8n user or access the workflow editor; they can review the output and respond directly through Telegram.

You could also put an IF before the approval step for conditional rules, e.g. only require approval when an amount exceeds a certain threshold.

You can use another messaging app integration instead of Telegram, but I just found Telegram node quite handy for this kind of human-in-the-loop workflow.

Cool to see more people working on this problem — the space is early enough that awareness benefits everyone.

We’re building Guardian Safety Gate from a different angle. Where your approach covers the full governance lifecycle (discover → classify → enforce → prove), Guardian is narrower and deeper on one moment: the boundary between an agent deciding to act and the action actually running.

A few design choices that shaped us:

Deterministic enforcement only. Threshold, allowlist, denylist, aggregate rules — no AI in the decision loop. A rule that says “block if amount > 5000” is provable and auditable. We don’t offer AI-based evaluation because we believe the security boundary should be deterministic. But I can see the case for decision intelligence in ambiguous, context-dependent scenarios.

Default-deny. No matching policy = blocked. A gate that’s open by default isn’t a gate.

Atomic execution claims. Server-side idempotency prevents double-execution when agents retry timed-out calls. Duplicate or replayed executions are routed to denied, not the action.

Decision-execution separation. Guardian decides and records — it never performs the action itself. The security boundary can’t be compromised into executing something because it doesn’t execute anything.

Your discovery and compliance-mapping modules are genuinely complementary — Guardian handles the enforcement boundary, but we don’t scan for shadow agents or map to the EU AI Act. Different tools for different parts of the same problem.

What’s your take on the deterministic-vs-decision-intelligence tradeoff? That’s the design choice we went back and forth on the most.

Seconding the Telegram Send-and-Wait approach — it’s the lowest-friction option when approvers aren’t n8n users. Two things that made it reliable for me in production:

  • Put the actual write/side-effect after the wait, never before. The agent only ever proposes; the workflow commits. Easy to accidentally let the agent call the tool and “approve” the log afterwards — that’s what bites you.
  • Log one row per decision (action + params hash + verdict + approver + timestamp), INSERT-only, like Kimetai said. When something breaks at 2am that append-only table is the only thing that tells you what was actually approved vs. what the agent claims.

The Wait+Webhook audit gap goes away once the log is a separate append-only step instead of something you read back out of the execution.

This is exactly the pattern we built Guardian around — you’re describing the right architecture manually.

The “put the write after the wait” rule is our decision-execution separation: Guardian decides and records, the workflow commits. The agent proposes, the gate decides, the workflow executes — three separate steps, never collapsed. The moment you let the agent approve its own log is the moment your audit trail becomes the agent’s story instead of the truth.

Your INSERT-only log with params hash + verdict + approver + timestamp is essentially our hash-chained audit record. We chain the hashes — each record includes the previous record’s hash — so tampering with any entry breaks the chain and points at exactly which record was altered. Because “we logged it” and “we can prove nobody changed it” are two very different claims, and only one of them holds up when someone’s asking hard questions.

The thing that bit you — “accidentally letting the agent call the tool and approve the log afterwards” — is why we added atomic execution claims. After approval, the workflow calls Guardian’s Enforce endpoint, which fetches the authoritative intent server-side, validates the payload hash matches what was approved, and atomically claims execution. If the agent retries or someone replays the approval, it routes to denied. The workflow never trusts the agent’s claim about what was approved — it checks with Guardian.

The n8n Wait+Webhook pattern gets you started — but it leaves you building the hard parts by hand: the idempotency logic that prevents a retried agent call from double-firing a payment, the payload hash that proves the action approved is the action that ran, the hash chain that makes your audit log survive an auditor’s scrutiny instead of just a dashboard glance. Every one of those is a 2am incident waiting to happen when it’s duct-taped together with a Code node and good intentions. Guardian is what that architecture looks like when it’s been stress-tested, cryptographically signed, and built to hold up under the exact scenario you’re hoping never happens — but know will.

One extra guard I’d add is to split the workflow into prepare → approve → execute, and store the proposed action before the Wait node. The approval record should include a hash of the exact payload, approver, decision, timestamp, and expiry.

When resumed, execute only that stored, hash-matched payload; don’t let the model regenerate parameters after approval. I’d also use an idempotency key on the final API/database write and treat timeout as deny/manual review.

In my local lead-recovery workflow, keeping AI classification separate from booking and notification actions made the branches much easier to test and audit.

1 « J'aime »

That is the architecture, and you have put it more compactly than I manage to. Storing the proposed action before the wait and refusing to let the model regenerate parameters after approval is the part most people skip, and it is the part that actually matters.

Two things from having built it. Expiry is easy to add and easy to get wrong, because the real question is what an expired approval does to an in-flight workflow. The only safe answer I found is deny plus a record saying it expired, not silence. And the idempotency key belongs server side rather than in the workflow, because a retried agent call and a replayed approval are indistinguishable from inside n8n.

The gap none of us in this thread have closed: once the workflow executes, nothing proves the provider did what was approved. The hash proves what was authorised. It does not prove what happened. That one is turning out to be harder than the approval half.

Agreed; Authorization evidence and execution evidence are two separate things.

I’d model them as two immutable records linked by the same operation ID:

  1. Approval intent: approved payload hash, approver, decision, expiry and timestamp.

  2. Execution receipt: request hash, provider request/idempotency key, response status, provider reference and timestamps.

After execution, a reconciliation step could fetch the resulting resource from the provider and compare its authoritative fields with the approved payload. A signed provider webhook or receipt would be even better when available.

If the provider offers neither signed receipts nor a reliable read API, I don’t think we can cryptographically prove the outcome; We can only preserve strong audit evidence and flag discrepancies.

I also agree about idempotency: n8n can carry the key, but enforcement has to live in the system performing the side effect. And expiry should become an explicit terminal “denied: expired” record, never an abandoned wait.

Someone else working through this separately landed on the same two-record split, which makes me more confident the shape is right.

The part I had not considered is signed provider receipts. Everything else in the ladder is still an assertion by whoever holds the credential, including the read-back, because they are the one reporting that it matched. A signed Stripe webhook is different. My side can verify the signature without trusting the reporter. So there is a level where the evidence stands on its own, and it is not the same as a read-back.

On idempotency I would split it one step further. My side can guarantee a second execution claim is refused, because the execution record is unique on the intent id. It cannot guarantee a second charge does not happen. Only the provider’s idempotency key does that. Two different guarantees, worth not collapsing.

Expiry as an explicit terminal record is what I have. An abandoned wait is indistinguishable from a system that never decided.

@Mukarram_Ali1 your execution receipt is the record I mint, and @BorkoB has put his finger on why it has to come from a third layer: a read-back reported by the credential holder is the credential holder’s word. So move the credential.

For the model hop, the pipe holds the provider credential and makes the call itself. Every crossing comes back with a signed receipt carrying the SHA-256 of the exact request the pipe sent and the SHA-256 of the exact body it returned, the hit-or-miss verdict, the price, the time, and the operation ID you planted: session header in, same ID inside the signed record out. A replayed answer and the call that originally filled it carry the same two digests, so “the action that ran is the action that was approved” is a byte comparison, not a reading of the agent’s log. Ed25519, RFC 7638 key id, public key directory, verifies in a browser with no account.

The fill, minted by the control plane:

The replay a second later, served by the edge, a different plane and a different signing moment, same two digests:

The session both sit in, the id is the planted one:

For the read-back, the same pipe makes the GET to Stripe or the CRM on the workflow’s behalf and signs that crossing with the digest of what came back. The reporter is now a layer that is neither the agent, nor the workflow, nor the vendor, and it signs with a key none of them ever held. That is the rung between “the credential holder says it matched” and “a signed Stripe webhook”: checkable without trusting the party that benefits.

Signature hashes are sealed into hourly Merkle slots at /v1/public/clock, roots anchored through OpenTimestamps, so when is proven the same way as what.

Record two, the write, is signed by whoever makes the write. The pipe makes model calls and reads; it does not make the write and does not put its name on it.

1 « J'aime »

Your tagline says it: recorder, not governor. Guardian is the governor half. I proposed a one-ID test run in the 3 AM thread, so let’s keep it there.

@BorkoB agreed, the one-ID run continues in your thread. One thing belongs here because the three receipts are in this post: they are leaves 0, 1 and 3 of the sealed hour 20261012T04Z (16 leaves, root 5dfe2b1f306927f35a8634d38a382cdcabaa3c6dfba83575b2d79336e2821e16). The hour’s full leaf list is public at

https://api.metrecept.co.uk/v1/public/clock/slots/20261012T04Z

and any of the three can be checked for inclusion with

curl "https://api.metrecept.co.uk/v1/public/clock/inclusion?receipt=<the JWS after the # in any link above>"

Change a byte of a receipt and it is not in that list. Correction to my post above: the OpenTimestamps commitment for this hour has not been written yet; the slot says commitment: null and will say otherwise when it has.

Edit, 13 October: answered in the 3 AM thread, where you asked to keep it, by editing my post there (#39) with a fresh one-ID run from tonight: Your AI agent can drain your Stripe account at 3am and n8n won't stop it - #39 by iwasinnam21