This workflow follows the Airtable → HTTP Request recipe pattern — see all workflows that pair these two integrations.
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 →
{
"name": "WhatsApp Multi-Agent (Security Patched)",
"nodes": [
{
"parameters": {},
"id": "8a1b72e8-b183-4c7c-adaf-828ffb9e4f7a",
"name": "Manual Trigger",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-3232,
736
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "whatsapp-webhook",
"responseMode": "responseNode",
"options": {}
},
"id": "6b4625d5-f23a-4800-8095-5db67a153efd",
"name": "WhatsApp Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-3088,
832
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "agent-status",
"responseMode": "responseNode",
"options": {}
},
"id": "e212f823-dbe9-4190-b578-139bfb1dfd36",
"name": "Agent Status Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-3008,
464
]
},
{
"parameters": {
"jsCode": "// STEP 1: Parse Message - Dual Format (Cloud API + Twilio)\n// Auto-detects incoming format and normalizes to common structure\n\ntry {\n const data = $input.first().json;\n const now = Date.now();\n let to, from, body, messageId, waId, profileName, hasMedia, mediaUrl, mediaType, isGroup, msgType;\n\n if (data.object === 'whatsapp_business_account') {\n // ========== CLOUD API FORMAT ==========\n const entry = data.entry?.[0];\n const change = entry?.changes?.[0];\n const value = change?.value;\n const message = value?.messages?.[0];\n const contact = value?.contacts?.[0];\n const metadata = value?.metadata;\n\n if (!message) {\n return {\n parseSuccess: false,\n error: true,\n errorType: 'not_a_message',\n errorMessage: 'Webhook event is not a message (possibly a status update)',\n timestamp: new Date().toISOString()\n };\n }\n\n to = metadata?.display_phone_number?.replace(/\\\\D/g, '') || metadata?.phone_number_id || '';\n from = (message.from || '').replace(/\\\\D/g, '');\n waId = contact?.wa_id || from;\n profileName = contact?.profile?.name || '';\n messageId = message.id || `msg_${now}`;\n msgType = message.type || 'text';\n hasMedia = false;\n\n if (msgType === 'text') {\n body = message.text?.body || '';\n } else if (['image', 'video', 'audio', 'document'].includes(msgType)) {\n body = message[msgType]?.caption || '';\n hasMedia = true;\n mediaUrl = message[msgType]?.id || null;\n mediaType = message[msgType]?.mime_type || msgType;\n } else if (msgType === 'location') {\n body = `Location: ${message.location?.latitude}, ${message.location?.longitude}`;\n } else if (msgType === 'contacts') {\n body = `Shared contact: ${message.contacts?.[0]?.name?.formatted_name || 'Unknown'}`;\n } else {\n body = '';\n }\n\n isGroup = false;\n mediaUrl = mediaUrl || null;\n mediaType = mediaType || null;\n\n } else {\n // ========== TWILIO FORMAT ==========\n to = (data.To || data.to || '').replace(/\\\\D/g, '');\n from = (data.From || data.from || '').replace(/\\\\D/g, '');\n waId = data.WaId || data.waId || from;\n profileName = data.ProfileName || data.profileName || '';\n messageId = data.MessageSid || data.messageSid || `msg_${now}`;\n body = data.Body || data.body || '';\n\n const numMedia = parseInt(data.NumMedia || data.numMedia || '0');\n hasMedia = numMedia > 0;\n mediaUrl = data.MediaUrl0 || data.mediaUrl0 || null;\n mediaType = data.MediaContentType0 || data.mediaContentType0 || null;\n\n isGroup = from.includes('-') ||\n (data.From || '').includes('@g.us') ||\n !!data.GroupId || !!data.groupId ||\n !!data.Participant || !!data.participant;\n\n if (!hasMedia) { msgType = 'text'; }\n else if (mediaType?.includes('image')) { msgType = 'image'; }\n else if (mediaType?.includes('video')) { msgType = 'video'; }\n else if (mediaType?.includes('audio')) { msgType = 'audio'; }\n else { msgType = 'document'; }\n }\n\n if (!to || !from) {\n throw new Error('Missing required fields: To or From');\n }\n\n // SECURITY: Sanitize message body\n body = (body || '').trim();\n body = body.replace(/[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]/g, '');\n if (body.length > 2000) {\n body = body.substring(0, 2000);\n }\n\n return {\n messageId: messageId,\n to: to,\n from: from,\n waId: waId,\n body: body,\n type: msgType,\n isGroup: isGroup,\n groupId: isGroup ? (data.GroupId || data.groupId || from.split('-')[0]) : null,\n participant: isGroup ? (data.Participant || data.participant || from.split('-')[1]) : null,\n hasMedia: hasMedia,\n mediaUrl: mediaUrl,\n mediaType: mediaType,\n profileName: profileName,\n timestamp: new Date().toISOString(),\n processingStartTime: now,\n parseSuccess: true,\n sourceFormat: data.object === 'whatsapp_business_account' ? 'cloud_api' : 'twilio'\n };\n\n} catch (error) {\n return {\n parseSuccess: false,\n error: true,\n errorType: 'parse_error',\n errorMessage: error.message,\n timestamp: new Date().toISOString()\n };\n}"
},
"id": "b250dba8-6bfa-4e87-9c74-c79932f17dd5",
"name": "1 Parse Message",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2800,
752
],
"alwaysOutputData": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": false
},
"conditions": [
{
"leftValue": "={{ $json.parseSuccess }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "b555a53a-9aee-4dae-8aaa-b7b70807f763",
"name": "Valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-2608,
752
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": false
},
"conditions": [
{
"leftValue": "={{ $json.isGroup }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "20e4ecb7-60df-42ed-83f9-d95a85993ac5",
"name": "2 Block Groups?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-2400,
656
]
},
{
"parameters": {
"operation": "search",
"base": {
"__rl": true,
"value": "appzcZpiIZ6QPtJXT",
"mode": "list"
},
"table": {
"__rl": true,
"value": "tblAgents",
"mode": "list"
},
"filterByFormula": "=AND({whatsapp_number} = '{{ $json.to }}', {is_active} = TRUE())",
"options": {}
},
"id": "518c594c-65b8-4374-a97c-4e74440a709a",
"name": "3 Find Agent",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2,
"position": [
-2208,
560
],
"retryOnFail": true,
"maxTries": 3,
"waitBetween": 500,
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": false
},
"conditions": [
{
"leftValue": "={{ $json.id }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "isNotEmpty"
}
}
]
},
"options": {}
},
"id": "51ae3209-12ae-42f0-b17c-8e86ace25b95",
"name": "Agent Found?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-2000,
560
]
},
{
"parameters": {
"jsCode": "// STEP 4: Merge Agent Data\n// SECURITY FIX: Separate sensitive fields from main data flow\n// Access token is stored in _private (not passed to AI)\n\nconst message = $node['2 Block Groups?'].json;\nconst agentRecord = $input.first().json;\nconst fields = agentRecord.fields || agentRecord;\n\n// SECURITY: Sensitive data stored separately - NOT passed downstream to AI\nconst _private = {\n whatsappAccessToken: fields.whatsapp_access_token || fields.cloud_api_access_token || '',\n whatsappBusinessAccountId: fields.whatsapp_business_account_id || '',\n whatsappPhoneNumberId: fields.whatsapp_phone_number_id || fields.cloud_api_phone_number_id || '',\n twilioSubAccountSid: fields.twilio_sub_account_sid || '',\n twilioAuthToken: fields.twilio_auth_token || ''\n};\n\nconst agent = {\n recordId: agentRecord.id,\n id: fields.agent_id || agentRecord.id,\n name: fields.agent_name || 'Agent',\n email: fields.email || '',\n whatsappNumber: fields.whatsapp_number || '',\n companyName: fields.company_name || 'Real Estate Agency',\n region: fields.region || '',\n language: fields.language || 'en',\n timezone: fields.timezone || 'UTC',\n isActive: fields.is_active !== false,\n autoReply: fields.auto_reply !== false,\n isOnline: fields.is_online === true,\n lastSeen: fields.last_seen || null,\n onlineThresholdMinutes: parseInt(fields.online_threshold_minutes || '5'),\n googleCalendarId: fields.google_calendar_id || 'primary',\n airtableBaseId: fields.airtable_base_id || 'appzcZpiIZ6QPtJXT',\n aiModel: fields.ai_model || 'gpt-4',\n aiTemperature: parseFloat(fields.ai_temperature || '0.7'),\n maxResponseLength: parseInt(fields.max_response_length || '300'),\n primaryApi: fields.primary_api || 'cloud_api'\n};\n\nlet currentlyOnline = agent.isOnline;\nif (agent.lastSeen) {\n const minutesSinceLastSeen = (Date.now() - new Date(agent.lastSeen).getTime()) / 60000;\n if (minutesSinceLastSeen > agent.onlineThresholdMinutes) {\n currentlyOnline = false;\n }\n}\n\nreturn {\n messageId: message.messageId,\n from: message.from,\n to: message.to,\n waId: message.waId,\n body: message.body,\n type: message.type,\n profileName: message.profileName,\n hasMedia: message.hasMedia,\n mediaUrl: message.mediaUrl,\n mediaType: message.mediaType,\n agent: agent,\n agentId: agent.id,\n agentName: agent.name,\n agentRecordId: agent.recordId,\n agentIsOnline: currentlyOnline,\n replyTo: `whatsapp:+${message.from}`,\n replyFrom: `whatsapp:+${message.to}`,\n timestamp: message.timestamp,\n processingStartTime: message.processingStartTime,\n _private: _private\n};"
},
"id": "d5f1e1e5-9354-43c2-94e5-37d0457b5608",
"name": "4 Merge Agent Data",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1808,
464
],
"alwaysOutputData": true
},
{
"parameters": {
"url": "=https://graph.facebook.com/v18.0/{{ $json._private.whatsappPhoneNumberId }}/{{ $json.waId }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $json._private.whatsappAccessToken }}"
}
]
},
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 5000
}
},
"id": "1f5eb578-7589-4f24-b938-ca250ec8bf25",
"name": "5 Get Contact Info",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
-1600,
464
],
"credentials": {
"httpHeaderAuth": {
"name": "<your credential>"
}
},
"continueOnFail": true
},
{
"parameters": {
"jsCode": "// STEP 6: Check Blocking Conditions\n// SECURITY FIX: Strip _private from output so tokens don't flow to AI\n\nconst message = $node['4 Merge Agent Data'].json;\nconst contactResponse = $input.first().json;\n\nlet shouldBlock = false;\nlet blockReason = null;\nlet contactLabels = [];\nlet isPinned = false;\n\nif (contactResponse && !contactResponse.error) {\n contactLabels = contactResponse.labels || [];\n isPinned = contactResponse.is_pinned === true || contactResponse.isPinned === true;\n \n const dntLabels = ['dnt', 'do not track', 'donottrack', 'no ai', 'noai', 'opt out', 'optout'];\n const hasDNT = contactLabels.some(label => {\n const labelName = (typeof label === 'string' ? label : label.name || '').toLowerCase().trim();\n return dntLabels.includes(labelName);\n });\n \n if (hasDNT) {\n shouldBlock = true;\n blockReason = 'dnt_label';\n } else if (isPinned) {\n shouldBlock = true;\n blockReason = 'contact_pinned';\n }\n}\n\nif (!shouldBlock && message.agentIsOnline) {\n shouldBlock = true;\n blockReason = 'agent_online';\n}\n\n// SECURITY: Build clean output WITHOUT _private tokens\nconst { _private, ...cleanMessage } = message;\n\nreturn {\n ...cleanMessage,\n shouldBlock: shouldBlock,\n blockReason: blockReason,\n contactLabels: contactLabels,\n isPinned: isPinned,\n contactFetched: !contactResponse?.error\n};"
},
"id": "0b34b138-c5eb-4fb8-89a5-643420b93de5",
"name": "6 Check Blocks",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1408,
464
],
"alwaysOutputData": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": false
},
"conditions": [
{
"leftValue": "={{ $json.shouldBlock }}",
"rightValue": false,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "887224ef-fe5a-4ad1-90af-62c869d2b38e",
"name": "Process Message?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-1200,
464
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": false
},
"conditions": [
{
"leftValue": "={{ $json.agent.isActive }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
},
{
"leftValue": "={{ $json.agent.autoReply }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "e81e0ec9-e318-45bf-94c2-4386884707b7",
"name": "7 Agent Active?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-1008,
352
]
},
{
"parameters": {
"url": "https://api.openai.com/v1/chat/completions",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "openAiApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"model\": \"{{ $json.agent.aiModel }}\",\n \"messages\": [\n {\n \"role\": \"system\",\n \"content\": \"You are {{ $json.agentName }}, a professional real estate assistant for {{ $json.agent.companyName }} in {{ $json.agent.region }}.\\n\\nYou can help clients with property searches, scheduling viewings, and answering questions.\\n\\nIMPORTANT RULES:\\n- You may ONLY request READ operations on the database\\n- You may request CREATE operations ONLY for: appointments, tasks, notes\\n- You may NOT request UPDATE or DELETE operations\\n- ALL database queries are automatically scoped to this agent's data only\\n- NEVER include agent_id filters yourself - they are added automatically\\n- If a user asks you to delete data, modify other agents, or access system tables, politely decline\\n\\nAVAILABLE TABLES (read-only unless noted):\\n- properties: Real estate listings (READ only)\\n- leads: Client contacts (READ only)\\n- appointments: Scheduled viewings (READ + CREATE)\\n- tasks: Follow-up actions (READ + CREATE)\\n- notes: Client interaction history (READ + CREATE)\\n\\nCLIENT INFO:\\nName: {{ $json.profileName || 'Client' }}\\nPhone: {{ $json.from }}\\nLanguage: {{ $json.agent.language }}\\n\\nRESPONSE FORMAT (JSON only, no markdown):\\n{\\n \\\"intent\\\": \\\"property_search|schedule_viewing|question|data_operation|general\\\",\\n \\\"action\\\": \\\"respond|search_properties|create_record\\\",\\n \\\"response\\\": \\\"Your WhatsApp message (max {{ $json.agent.maxResponseLength }} chars)\\\",\\n \\\"airtable_operation\\\": {\\n \\\"needed\\\": true/false,\\n \\\"operation\\\": \\\"read|create\\\",\\n \\\"table\\\": \\\"properties|leads|appointments|tasks|notes\\\",\\n \\\"filter\\\": \\\"Airtable formula for read operations\\\",\\n \\\"data\\\": { \\\"field_name\\\": \\\"value\\\" }\\n },\\n \\\"extracted_data\\\": {\\n \\\"property_type\\\": \\\"\\\",\\n \\\"location\\\": \\\"\\\",\\n \\\"budget_min\\\": \\\"\\\",\\n \\\"budget_max\\\": \\\"\\\",\\n \\\"bedrooms\\\": \\\"\\\",\\n \\\"date_time\\\": \\\"\\\"\\n },\\n \\\"confidence\\\": 0.0-1.0\\n}\\n\\nLanguage: {{ $json.agent.language }}\\nTimezone: {{ $json.agent.timezone }}\"\n },\n {\n \"role\": \"user\",\n \"content\": \"{{ $json.body }}\"\n }\n ],\n \"temperature\": {{ $json.agent.aiTemperature }},\n \"max_tokens\": 1000\n}",
"options": {
"timeout": 30000
}
},
"id": "2519a034-1734-4246-80f3-b6206f689cbc",
"name": "8 AI Analysis",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
-800,
256
],
"retryOnFail": true,
"maxTries": 2,
"waitBetween": 2000,
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"jsCode": "// STEP 9: Parse AI Decision\n// SECURITY FIX: Validate and restrict AI-requested operations\n// - Only allow read/create (no update/delete)\n// - Only allow known tables\n// - Inject agent_id scoping into all filters and creates\n\nconst message = $node['7 Agent Active?'].json;\nconst aiResponse = $input.first().json;\n\nlet parsed = {\n intent: 'general',\n action: 'respond',\n response: 'Thank you for your message. How can I assist you today?',\n airtable_operation: { needed: false, operation: 'read', table: 'properties', filter: '', data: {} },\n extracted_data: {},\n confidence: 0.5\n};\n\ntry {\n const content = aiResponse.choices?.[0]?.message?.content || '';\n const jsonMatch = content.match(/```(?:json)?\\s*([\\s\\S]*?)\\s*```/);\n const jsonString = jsonMatch ? jsonMatch[1] : content;\n const aiParsed = JSON.parse(jsonString.trim());\n \n parsed = {\n intent: aiParsed.intent || parsed.intent,\n action: aiParsed.action || parsed.action,\n response: aiParsed.response || parsed.response,\n airtable_operation: {\n needed: aiParsed.airtable_operation?.needed || false,\n operation: aiParsed.airtable_operation?.operation || 'read',\n table: aiParsed.airtable_operation?.table || 'properties',\n filter: aiParsed.airtable_operation?.filter || '',\n data: aiParsed.airtable_operation?.data || {}\n },\n extracted_data: aiParsed.extracted_data || {},\n confidence: aiParsed.confidence || parsed.confidence\n };\n \n const maxLength = message.agent.maxResponseLength || 300;\n if (parsed.response.length > maxLength) {\n parsed.response = parsed.response.substring(0, maxLength - 3) + '...';\n }\n} catch (error) {\n // Use default parsed object\n}\n\n// =========================================\n// SECURITY: Validate and scope AI operations\n// =========================================\nconst agentId = message.agentId;\nconst op = parsed.airtable_operation;\n\nif (op.needed) {\n // 1. RESTRICT operations: only read and create allowed\n const allowedOps = ['read', 'create'];\n if (!allowedOps.includes(op.operation)) {\n op.needed = false;\n parsed.response += '\\n\\n_That operation is not permitted._';\n }\n \n // 2. RESTRICT tables\n const allowedTables = ['properties', 'leads', 'appointments', 'tasks', 'notes'];\n if (!allowedTables.includes(op.table)) {\n op.needed = false;\n parsed.response += '\\n\\n_That table is not accessible._';\n }\n \n // 3. CREATE: only allowed on appointments, tasks, notes\n const createAllowedTables = ['appointments', 'tasks', 'notes'];\n if (op.operation === 'create' && !createAllowedTables.includes(op.table)) {\n op.needed = false;\n parsed.response += '\\n\\n_Cannot create records in that table._';\n }\n \n // 4. INJECT agent_id scoping\n if (op.needed && op.operation === 'read') {\n const agentFilter = `{agent_id} = '${agentId}'`;\n if (op.filter && op.filter.trim()) {\n op.filter = `AND(${agentFilter}, ${op.filter})`;\n } else {\n op.filter = agentFilter;\n }\n }\n \n if (op.needed && op.operation === 'create') {\n op.data = op.data || {};\n op.data.agent_id = agentId;\n }\n}\n\nreturn {\n ...message,\n intent: parsed.intent,\n action: parsed.action,\n aiResponse: parsed.response,\n airtableOperation: op,\n extractedData: parsed.extracted_data,\n confidence: parsed.confidence\n};"
},
"id": "2d1751f7-f09b-4732-ba22-89b8a7d2299b",
"name": "9 Parse AI Decision",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-608,
256
],
"alwaysOutputData": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": false
},
"conditions": [
{
"leftValue": "={{ $json.airtableOperation.needed }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
]
},
"options": {}
},
"id": "ee587bb4-713b-426c-9335-3c906ec00fa6",
"name": "Need Airtable?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-400,
256
]
},
{
"parameters": {
"jsCode": "// STEP 10: Route Operation\n// SECURITY FIX: Validates operation type before routing\n\nconst data = $input.first().json;\nconst op = data.airtableOperation;\n\nconst validOperations = ['read', 'create'];\nif (!validOperations.includes(op.operation)) {\n throw new Error(`Blocked operation: ${op.operation}`);\n}\n\nconst validTables = ['properties', 'leads', 'appointments', 'tasks', 'notes'];\nif (!validTables.includes(op.table)) {\n throw new Error(`Blocked table: ${op.table}`);\n}\n\nreturn {\n ...data,\n airtableRoute: op.operation,\n airtableTable: op.table,\n airtableFilter: op.filter || '',\n airtableData: op.data || {},\n airtableBaseId: data.agent.airtableBaseId\n};"
},
"id": "21295061-2726-4b40-8ae1-33a3baaf67bb",
"name": "Route Operation",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-208,
160
],
"alwaysOutputData": true
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.airtableRoute }}",
"rightValue": "create",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputIndex": 0
},
{
"conditions": {
"conditions": [
{
"leftValue": "={{ $json.airtableRoute }}",
"rightValue": "read",
"operator": {
"type": "string",
"operation": "equals"
}
}
]
},
"outputIndex": 1
}
]
},
"options": {}
},
"id": "a1b2c3d4-switch-node-for-routing",
"name": "CRUD Switch",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
0,
160
],
"notes": "SECURITY FIX: Routes to ONLY the correct operation (was parallel before)"
},
{
"parameters": {
"operation": "create",
"base": {
"__rl": true,
"value": "={{ $json.airtableBaseId }}",
"mode": "id"
},
"table": {
"__rl": true,
"value": "={{ $json.airtableTable }}",
"mode": "name"
},
"columns": {
"mappingMode": "defineBelow",
"value": "={{ $json.airtableData }}"
},
"options": {}
},
"id": "1aacd972-703f-4722-88d0-9e18f023837b",
"name": "CREATE Record",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2,
"position": [
208,
64
],
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"continueOnFail": true
},
{
"parameters": {
"operation": "search",
"base": {
"__rl": true,
"value": "={{ $json.airtableBaseId }}",
"mode": "id"
},
"table": {
"__rl": true,
"value": "={{ $json.airtableTable }}",
"mode": "name"
},
"filterByFormula": "={{ $json.airtableFilter }}",
"options": {}
},
"id": "1b4be86d-14fc-423f-a5c8-25ed6682c262",
"name": "READ Records",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2,
"position": [
208,
256
],
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"continueOnFail": true
},
{
"parameters": {
"jsCode": "// STEP 11: Prepare Final Response\n// SECURITY FIX: Redacts sensitive data, uses safe references\n\nconst message = $node['9 Parse AI Decision'].json;\nlet finalResponse = message.aiResponse;\nlet airtableSuccess = false;\n\nif ($input.first()?.json && message.airtableOperation?.needed) {\n const result = $input.first().json;\n if (!result.error) {\n airtableSuccess = true;\n if (!finalResponse.toLowerCase().includes('updated') &&\n !finalResponse.toLowerCase().includes('created') &&\n !finalResponse.toLowerCase().includes('saved')) {\n finalResponse += ' Done.';\n }\n } else {\n finalResponse += '\\n\\n_Note: Database update is pending._';\n }\n}\n\nif (!finalResponse.includes(message.agentName) &&\n !finalResponse.includes(message.agent.companyName) &&\n finalResponse.length < message.agent.maxResponseLength - 50) {\n finalResponse += `\\n\\n-- ${message.agentName}\\n${message.agent.companyName}`;\n}\n\nconst processingTime = Date.now() - message.processingStartTime;\n\nreturn {\n messageId: message.messageId,\n to: message.replyTo,\n from: message.replyFrom,\n body: finalResponse,\n agentId: message.agentId,\n agentName: message.agentName,\n airtableExecuted: message.airtableOperation?.needed || false,\n airtableSuccess: airtableSuccess,\n airtableOperation: message.airtableOperation?.operation || null,\n airtableTable: message.airtableOperation?.table || null,\n processingTimeMs: processingTime,\n processingTimeSec: (processingTime / 1000).toFixed(2),\n readyToSend: true,\n timestamp: new Date().toISOString(),\n context: {\n intent: message.intent,\n action: message.action,\n confidence: message.confidence\n }\n};"
},
"id": "5330d156-4fcf-422c-a67d-7cb2c8bd3cde",
"name": "Prepare Response",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
416,
160
],
"alwaysOutputData": true
},
{
"parameters": {
"method": "POST",
"url": "=https://graph.facebook.com/v18.0/{{ $('4 Merge Agent Data').first().json._private.whatsappPhoneNumberId }}/messages",
"authentication": "none",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Authorization",
"value": "=Bearer {{ $('4 Merge Agent Data').first().json._private.whatsappAccessToken }}"
},
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"messaging_product\": \"whatsapp\",\n \"to\": \"{{ $json.to.replace('whatsapp:', '').replace('+', '') }}\",\n \"type\": \"text\",\n \"text\": {\n \"body\": {{ JSON.stringify($json.body) }}\n }\n}",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 10000
}
},
"id": "32dcb92d-c52c-4f11-890d-ecbdf135a5e9",
"name": "Send WhatsApp",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4,
"position": [
608,
160
],
"retryOnFail": true,
"maxTries": 3,
"waitBetween": 1000,
"continueOnFail": true
},
{
"parameters": {
"operation": "create",
"base": {
"__rl": true,
"value": "appzcZpiIZ6QPtJXT",
"mode": "list"
},
"table": {
"__rl": true,
"value": "tblMessageLog",
"mode": "list"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"timestamp": "={{ $json.timestamp }}",
"message_id": "={{ $json.messageId }}",
"agent_id": "={{ $json.agentId }}",
"agent_name": "={{ $json.agentName }}",
"from_number": "={{ $json.to.replace('whatsapp:', '') }}",
"to_number": "={{ $json.from.replace('whatsapp:', '') }}",
"message_body": "={{ $json.body.substring(0, 500) }}",
"intent": "={{ $json.context.intent }}",
"action": "={{ $json.context.action }}",
"confidence": "={{ $json.context.confidence }}",
"airtable_executed": "={{ $json.airtableExecuted }}",
"airtable_success": "={{ $json.airtableSuccess }}",
"airtable_operation": "={{ $json.airtableOperation }}",
"airtable_table": "={{ $json.airtableTable }}",
"processing_time_ms": "={{ $json.processingTimeMs }}",
"status": "sent"
}
},
"options": {}
},
"id": "1130c457-53a8-4875-888a-a97b0f63fb49",
"name": "Log Success",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2,
"position": [
816,
160
],
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"continueOnFail": true
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={ \"success\": true, \"messageId\": \"{{ $json.messageId }}\", \"agent\": \"{{ $json.agentName }}\", \"processingTime\": \"{{ $json.processingTimeSec }}s\" }",
"options": {}
},
"id": "3a06a6b3-1bc0-4f4a-8b80-9fcbfed4cce2",
"name": "Success Response",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
1024,
160
]
},
{
"parameters": {
"operation": "create",
"base": {
"__rl": true,
"value": "appzcZpiIZ6QPtJXT",
"mode": "list"
},
"table": {
"__rl": true,
"value": "tblBlockedMessages",
"mode": "list"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"timestamp": "={{ $json.timestamp }}",
"from_number": "={{ $json.from }}",
"to_number": "={{ $json.to }}",
"message_preview": "={{ $json.body.substring(0, 100) }}",
"block_reason": "={{ $json.blockReason || ($json.isGroup ? 'group_message' : 'unknown') }}",
"agent_id": "={{ $json.agentId || 'not_found' }}",
"is_group": "={{ $json.isGroup || false }}",
"is_pinned": "={{ $json.isPinned || false }}",
"agent_online": "={{ $json.agentIsOnline || false }}"
}
},
"options": {}
},
"id": "20052dd8-0d6f-4e65-9025-9980d03e4b1d",
"name": "Log Blocked",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2,
"position": [
-1200,
656
],
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"continueOnFail": true
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={ \"blocked\": true, \"reason\": \"{{ $json.blockReason || 'unknown' }}\" }",
"options": {}
},
"id": "5b063f2d-b7b6-461e-8d53-11eee63a603a",
"name": "Blocked Response",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
-1008,
656
]
},
{
"parameters": {
"operation": "create",
"base": {
"__rl": true,
"value": "appzcZpiIZ6QPtJXT",
"mode": "list"
},
"table": {
"__rl": true,
"value": "tblErrors",
"mode": "list"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"timestamp": "={{ $now.toISO() }}",
"error_type": "={{ $json.errorType || 'unknown' }}",
"error_message": "={{ $json.errorMessage || $json.error || 'Unknown error' }}",
"execution_id": "={{ $execution.id }}",
"workflow_name": "={{ $workflow.name }}"
}
},
"options": {}
},
"id": "262b9046-a6bd-4493-9b52-a2905f5ebf4d",
"name": "Log Error",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2,
"position": [
-2608,
960
],
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"continueOnFail": true
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={ \"error\": true, \"message\": \"Invalid request\" }",
"options": {}
},
"id": "a218ce11-4bc4-4edf-91e7-ba8d36f0bc93",
"name": "Error Response",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
-2848,
1072
]
},
{
"parameters": {
"jsCode": "// Agent Status Update Parser\n\nconst data = $input.first().json;\n\nif (!data.agent_id) {\n throw new Error('Missing required field: agent_id');\n}\n\nif (!data.status || !['online', 'offline'].includes(data.status)) {\n throw new Error('Status must be \"online\" or \"offline\"');\n}\n\nreturn {\n agentId: data.agent_id,\n status: data.status,\n source: data.source || 'api',\n timestamp: new Date().toISOString()\n};"
},
"id": "0d72f783-bb55-4d95-8165-77d61dab42ec",
"name": "Parse Status",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2816,
464
],
"alwaysOutputData": true
},
{
"parameters": {
"operation": "search",
"base": {
"__rl": true,
"value": "appzcZpiIZ6QPtJXT",
"mode": "list"
},
"table": {
"__rl": true,
"value": "tblAgents",
"mode": "list"
},
"filterByFormula": "={agent_id} = '{{ $json.agentId }}'",
"options": {}
},
"id": "cbb9f0c0-2e78-4633-a356-bad35fe0957b",
"name": "Find Agent",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2,
"position": [
-2608,
464
],
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"operation": "update",
"base": {
"__rl": true,
"value": "appzcZpiIZ6QPtJXT",
"mode": "list"
},
"table": {
"__rl": true,
"value": "tblAgents",
"mode": "list"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"is_online": "={{ $node['Parse Status'].json.status === 'online' }}",
"last_seen": "={{ $node['Parse Status'].json.timestamp }}",
"status_source": "={{ $node['Parse Status'].json.source }}"
}
},
"options": {}
},
"id": "eb99b004-41a0-4ca9-a451-b86e0b6aae99",
"name": "Update Status",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2,
"position": [
-2416,
464
],
"retryOnFail": true,
"maxTries": 3,
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={ \"success\": true, \"agent\": \"{{ $node['Parse Status'].json.agentId }}\", \"status\": \"{{ $node['Parse Status'].json.status }}\" }",
"options": {}
},
"id": "16f1039b-03dc-4229-96ac-00c20139f1ad",
"name": "Status Response",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
-2208,
560
]
},
{
"parameters": {},
"id": "241cf553-e127-4e5f-b9e1-57fefee95213",
"name": "Error Trigger",
"type": "n8n-nodes-base.errorTrigger",
"typeVersion": 1,
"position": [
-1968,
944
]
},
{
"parameters": {
"jsCode": "// Global Error Handler\n// SECURITY FIX: Redacts sensitive fields before logging\n\nconst error = $input.first().json;\n\nreturn {\n timestamp: new Date().toISOString(),\n errorType: 'workflow_error',\n errorMessage: (error.message || error.error || 'Unknown error').substring(0, 500),\n nodeName: error.node?.name || 'Unknown',\n nodeType: error.node?.type || 'Unknown',\n executionId: $execution.id,\n workflowName: $workflow.name,\n workflowId: $workflow.id\n};"
},
"id": "957816cd-8b5a-4914-abef-02b9db7f4144",
"name": "Handle Error",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1776,
944
],
"alwaysOutputData": true
},
{
"parameters": {
"operation": "create",
"base": {
"__rl": true,
"value": "appzcZpiIZ6QPtJXT",
"mode": "list"
},
"table": {
"__rl": true,
"value": "tblErrors",
"mode": "list"
},
"columns": {
"mappingMode": "defineBelow",
"value": {
"timestamp": "={{ $json.timestamp }}",
"error_type": "={{ $json.errorType }}",
"error_message": "={{ $json.errorMessage }}",
"node_name": "={{ $json.nodeName }}",
"node_type": "={{ $json.nodeType }}",
"execution_id": "={{ $json.executionId }}",
"workflow_name": "={{ $json.workflowName }}"
}
},
"options": {}
},
"id": "e21bdd57-df0b-4545-9719-4e1d6f47bd55",
"name": "Log to Airtable",
"type": "n8n-nodes-base.airtable",
"typeVersion": 2,
"position": [
-1568,
944
],
"credentials": {
"airtableTokenApi": {
"name": "<your credential>"
}
},
"continueOnFail": true
},
{
"parameters": {
"updates": [
"messages"
],
"options": {}
},
"type": "n8n-nodes-base.whatsAppTrigger",
"typeVersion": 1,
"position": [
-3312,
560
],
"id": "e9662fbb-c176-4564-8dae-ef6e5bae8ca8",
"name": "WhatsApp Trigger",
"credentials": {
"whatsAppTriggerApi": {
"name": "<your credential>"
}
}
}
],
"connections": {
"WhatsApp Webhook": {
"main": [
[
{
"node": "Error Response",
"type": "main",
"index": 0
},
{
"node": "1 Parse Message",
"type": "main",
"index": 0
}
]
]
},
"Agent Status Webhook": {
"main": [
[
{
"node": "Parse Status",
"type": "main",
"index": 0
}
]
]
},
"1 Parse Message": {
"main": [
[
{
"node": "Valid?",
"type": "main",
"index": 0
}
]
]
},
"Valid?": {
"main": [
[
{
"node": "2 Block Groups?",
"type": "main",
"index": 0
}
],
[
{
"node": "Log Error",
"type": "main",
"index": 0
}
]
]
},
"2 Block Groups?": {
"main": [
[
{
"node": "3 Find Agent",
"type": "main",
"index": 0
}
],
[
{
"node": "Log Blocked",
"type": "main",
"index": 0
}
]
]
},
"3 Find Agent": {
"main": [
[
{
"node": "Agent Found?",
"type": "main",
"index": 0
}
]
]
},
"Agent Found?": {
"main": [
[
{
"node": "4 Merge Agent Data",
"type": "main",
"index": 0
}
],
[
{
"node": "Log Blocked",
"type": "main",
"index": 0
}
]
]
},
"4 Merge Agent Data": {
"main": [
[
{
"node": "5 Get Contact Info",
"type": "main",
"index": 0
}
]
]
},
"5 Get Contact Info": {
"main": [
[
{
"node": "6 Check Blocks",
"type": "main",
"index": 0
}
]
]
},
"6 Check Blocks": {
"main": [
[
{
"node": "Process Message?",
"type": "main",
"index": 0
}
]
]
},
"Process Message?": {
"main": [
[
{
"node": "7 Agent Active?",
"type": "main",
"index": 0
}
],
[
{
"node": "Log Blocked",
"type": "main",
"index": 0
}
]
]
},
"7 Agent Active?": {
"main": [
[
{
"node": "8 AI Analysis",
"type": "main",
"index": 0
}
],
[
{
"node": "Log Blocked",
"type": "main",
"index": 0
}
]
]
},
"8 AI Analysis": {
"main": [
[
{
"node": "9 Parse AI Decision",
"type": "main",
"index": 0
}
]
]
},
"9 Parse AI Decision": {
"main": [
[
{
"node": "Need Airtable?",
"type": "main",
"index": 0
}
]
]
},
"Need Airtable?": {
"main": [
[
{
"node": "Route Operation",
"type": "main",
"index": 0
}
],
[
{
"node": "Prepare Response",
"type": "main",
"index": 0
}
]
]
},
"Route Operation": {
"main": [
[
{
"node": "CRUD Switch",
"type": "main",
"index": 0
}
]
]
},
"CRUD Switch": {
"main": [
[
{
"node": "CREATE Record",
"type": "main",
"index": 0
}
],
[
{
"node": "READ Records",
"type": "main",
"index": 0
}
]
]
},
"CREATE Record": {
"main": [
[
{
"node": "Prepare Response",
"type": "main",
"index": 0
}
]
]
},
"READ Records": {
"main": [
[
{
"node": "Prepare Response",
"type": "main",
"index": 0
}
]
]
},
"Prepare Response": {
"main": [
[
{
"node": "Send WhatsApp",
"type": "main",
"index": 0
}
]
]
},
"Send WhatsApp": {
"main": [
[
{
"node": "Log Success",
"type": "main",
"index": 0
}
]
]
},
"Log Success": {
"main": [
[
{
"node": "Success Response",
"type": "main",
"index": 0
}
]
]
},
"Log Blocked": {
"main": [
[
{
"node": "Blocked Response",
"type": "main",
"index": 0
}
]
]
},
"Log Error": {
"main": [
[]
]
},
"Parse Status": {
"main": [
[
{
"node": "Find Agent",
"type": "main",
"index": 0
}
]
]
},
"Find Agent": {
"main": [
[
{
"node": "Update Status",
"type": "main",
"index": 0
}
]
]
},
"Update Status": {
"main": [
[
{
"node": "Status Response",
"type": "main",
"index": 0
}
]
]
},
"Error Trigger": {
"main": [
[
{
"node": "Handle Error",
"type": "main",
"index": 0
}
]
]
},
"Handle Error": {
"main": [
[
{
"node": "Log to Airtable",
"type": "main",
"index": 0
}
]
]
}
}
}
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
airtableTokenApihttpHeaderAuthopenAiApiwhatsAppTriggerApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
WhatsApp Multi-Agent (Security Patched). Uses airtable, httpRequest, errorTrigger, whatsAppTrigger. Event-driven trigger; 36 nodes.
Source: https://github.com/ianavm/n8n-ai-workflow-manager/blob/master/workflows/_archive/whatsapp_multi_agent_patched.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.
WhatsApp Multi-Agent v2 (Cloud API). Uses whatsAppTrigger, httpRequest, airtable, errorTrigger. Event-driven trigger; 39 nodes.
This workflow implements an AI-powered WhatsApp booking assistant for a hair salon. The system allows customers to book, reschedule, or cancel appointments automatically via text or voice messages on
Whatsapp Multi Agent System optimized copy 2.0. Uses airtable, httpRequest, errorTrigger. Webhook trigger; 44 nodes.
This n8n template demonstrates how to automatically extract text content from PDF documents received via WhatsApp messages using OCR.
The AI-Powered Shopify SEO Content Automation is an enterprise-grade workflow that transforms product content creation for e-commerce stores. This sophisticated multi-agent system integrates GPT-4o, C