The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"id": "aicp-approval-gate",
"name": "Approval gate (reusable)",
"meta": {
"description": "Call via Execute Workflow with { title, message, source, autoApprove? }. Sends an approval request through the channel-abstracted Notify sub-workflow with Approve/Reject links, then WAITS until a link is clicked. Returns { approved, auto, timedOut }. Timeout (24h) = rejected \u2014 default deny. autoApprove:true short-circuits to approved (lets callers keep one linear flow and decide per-scope whether a human is required).",
"m5_identity_capture": "THREAT-MODEL M5 / RBAC: the resume URL below is an UNAUTHENTICATED capability link \u2014 anyone who holds it can approve, and the click carries NO approver identity. Router-side RBAC (router/rbac.js) enforces approver-group authorization + segregation of duties, but only if a TRUSTWORTHY approver identity reaches it. To bind approval to a NAMED approver: front the resume webhook with authenticated ingress (Google SSO / IAP), read the verified identity from the IAP headers (X-Goog-Authenticated-User-Email / X-Goog-Authenticated-User-Id), resolve that user's Google Group membership, and pass identity + groups to the router as `approver` + `approverGroups` (alongside the original `requester`) on the re-call with approved:true. Alternative: issue signed, short-lived resume links bound to a specific named approver instead of one shared URL. Until that ingress is in place the resume link remains a bearer capability \u2014 see RBAC.md."
},
"nodes": [
{
"parameters": {
"workflowInputs": {
"values": [
{
"name": "action",
"type": "string"
},
{
"name": "scope",
"type": "string"
},
{
"name": "riskTier",
"type": "string"
},
{
"name": "requester",
"type": "string"
},
{
"name": "dataTouched",
"type": "string"
},
{
"name": "preview",
"type": "string"
},
{
"name": "dimensions",
"type": "string"
},
{
"name": "autoApprove",
"type": "boolean"
}
]
}
},
"id": "abababab-0000-0000-0000-000000000001",
"name": "When called",
"type": "n8n-nodes-base.executeWorkflowTrigger",
"typeVersion": 1.1,
"position": [
200,
300
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true
},
"conditions": [
{
"leftValue": "={{ $json.autoApprove }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "abababab-0000-0000-0000-000000000002",
"name": "Auto-approve?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
420,
300
]
},
{
"parameters": {
"jsCode": "// Auto lane: no human needed for this caller/scope.\nreturn [{ json: { approved: true, auto: true, timedOut: false } }];"
},
"id": "abababab-0000-0000-0000-000000000003",
"name": "Approve (auto)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
180
]
},
{
"parameters": {
"jsCode": "// Build the human-facing request from the TYPED contract (G3).\n//\n// The gate used to render `${i.title}` + a free-text message. A human cannot\n// judge \"APPROVAL NEEDED: action\" \u2014 so they approve it, which is exactly the\n// rubber-stamp Lanham's second HITL decision warns about. The router already\n// computed every field below; this only has to show them.\nconst i = $input.first().json;\nconst url = $execution.resumeUrl;\n\n// FAIL CLOSED on an incomplete request. Rendering a prompt with blanks asks a\n// human to authorise something nobody described, and the old `|| 'action'`\n// fallback made that the DEFAULT rather than an error. Throwing here means no\n// approval is sent, which is the safe direction: nothing gets approved.\nconst required = ['action', 'scope', 'riskTier', 'requester', 'dataTouched', 'preview'];\nconst missing = required.filter((k) => i[k] === undefined || i[k] === null || i[k] === '');\nif (missing.length) {\n throw new Error(\n `approval-gate: refusing to ask for approval without [${missing.join(', ')}]. ` +\n `An approval prompt a human cannot judge is worse than no gate \u2014 they approve it. ` +\n `The caller must map these; workflow_lint.py enforces it.`);\n}\n\nconst row = (k, v) => ` ${String(k).padEnd(13)}${v}`;\nconst fired = i.dimensions ? ` (fired: ${i.dimensions})` : '';\n\nconst message = [\n `WHAT WILL HAPPEN IF YOU APPROVE`,\n ` ${i.action}`,\n ``,\n row('Scope', i.scope),\n row('Risk', `${i.riskTier}${fired}`),\n row('Requested by', i.requester),\n row('Data touched', i.dataTouched),\n ``,\n `PREVIEW`,\n String(i.preview).split('\\n').map((l) => ' ' + l).join('\\n'),\n ``,\n `\u2705 Approve: ${url}?approved=true`,\n `\u274c Reject: ${url}?approved=false`,\n ``,\n `No response within 24h = REJECTED. Rejecting costs a round trip;`,\n `approving something you could not verify costs whatever it does next.`,\n].join('\\n');\n\nreturn [{ json: {\n severity: 'warn',\n title: `APPROVAL NEEDED: ${i.action}`,\n message,\n source: i.source || 'approval-gate',\n} }];\n"
},
"id": "abababab-0000-0000-0000-000000000004",
"name": "Build request",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
660,
420
]
},
{
"parameters": {
"source": "database",
"workflowId": {
"__rl": true,
"value": "aicp-notify",
"mode": "id"
},
"options": {
"waitForSubWorkflow": true
},
"workflowInputs": {
"mappingMode": "autoMapInputData",
"value": {},
"matchingColumns": [],
"schema": [
{
"id": "severity",
"displayName": "severity",
"type": "string",
"required": false,
"defaultMatch": false,
"display": true,
"canBeUsedToMatch": true
},
{
"id": "title",
"displayName": "title",
"type": "string",
"required": false,
"defaultMatch": false,
"display": true,
"canBeUsedToMatch": true
},
{
"id": "message",
"displayName": "message",
"type": "string",
"required": false,
"defaultMatch": false,
"display": true,
"canBeUsedToMatch": true
},
{
"id": "source",
"displayName": "source",
"type": "string",
"required": false,
"defaultMatch": false,
"display": true,
"canBeUsedToMatch": true
}
],
"attemptToConvertTypes": false,
"convertFieldsToString": true
}
},
"id": "abababab-0000-0000-0000-000000000005",
"name": "Notify approvers",
"type": "n8n-nodes-base.executeWorkflow",
"typeVersion": 1.2,
"position": [
880,
420
]
},
{
"parameters": {
"resume": "webhook",
"httpMethod": "GET",
"limitWaitTime": true,
"limitType": "afterTimeInterval",
"resumeAmount": 24,
"resumeUnit": "hours",
"options": {}
},
"id": "abababab-0000-0000-0000-000000000006",
"name": "Wait for decision",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
1100,
420
]
},
{
"parameters": {
"jsCode": "// Resumed either by a clicked link (query.approved present) or by timeout.\n// Anything other than an explicit approved=true is a rejection \u2014 default deny.\nconst q = $input.first().json.query || {};\nconst timedOut = q.approved === undefined;\nreturn [{ json: {\n approved: q.approved === 'true',\n auto: false,\n timedOut\n} }];"
},
"id": "abababab-0000-0000-0000-000000000007",
"name": "Decision",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1320,
420
]
}
],
"connections": {
"When called": {
"main": [
[
{
"node": "Auto-approve?",
"type": "main",
"index": 0
}
]
]
},
"Auto-approve?": {
"main": [
[
{
"node": "Approve (auto)",
"type": "main",
"index": 0
}
],
[
{
"node": "Build request",
"type": "main",
"index": 0
}
]
]
},
"Build request": {
"main": [
[
{
"node": "Notify approvers",
"type": "main",
"index": 0
}
]
]
},
"Notify approvers": {
"main": [
[
{
"node": "Wait for decision",
"type": "main",
"index": 0
}
]
]
},
"Wait for decision": {
"main": [
[
{
"node": "Decision",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {},
"active": false
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Approval gate (reusable). Uses executeWorkflowTrigger. Event-driven trigger; 7 nodes.
Source: https://github.com/jgobuilds/ai-control-plane-public/blob/main/n8n-workflows/approval-gate.subworkflow.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Reagendamiento. Uses executeWorkflowTrigger, redis, n8n-nodes-evolution-api, dataTable. Event-driven trigger; 73 nodes.
Agendamiento. Uses n8n-nodes-evolution-api, redis, dataTable, executeWorkflowTrigger. Event-driven trigger; 60 nodes.
Prevent concurrent workflow runs using Redis. Uses executeWorkflowTrigger, manualTrigger, stickyNote, executeWorkflow. Event-driven trigger; 43 nodes.
This workflow sets a small "lock" value in Redis so that only one copy of a long job can run at the same time. If another trigger fires while the job is still busy, the workflow sees the lock, stops e
Cancelacion. Uses executeWorkflowTrigger, redis, n8n-nodes-evolution-api, dataTable. Event-driven trigger; 36 nodes.