This workflow follows the HTTP Request → Supabase 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 Assistant Base - P1",
"nodes": [
{
"parameters": {
"httpMethod": [
"GET",
"POST"
],
"path": "whatsapp",
"responseMode": "responseNode",
"options": {}
},
"id": "n1",
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
-480,
128
],
"multipleMethods": true
},
{
"parameters": {
"conditions": [
{
"id": "1",
"leftValue": "={{ $json.body && $json.body.entry ? 'false' : 'true' }}",
"rightValue": "true",
"operator": "string"
}
],
"options": {}
},
"id": "n2",
"name": "IF Verificaci\u00f3n",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-240,
128
]
},
{
"parameters": {
"respondWith": "text",
"responseBody": "={{ $json.query['hub.challenge'] }}",
"options": {}
},
"id": "n3",
"name": "Responder Challenge",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
0,
0
]
},
{
"parameters": {
"jsCode": "const mode = String($env.WEBHOOK_VERIFY_SIGNATURE || 'false').toLowerCase();\nconst passthrough = (verified, m) => [{ json: Object.assign({ verified, mode: m }, $input.first().json) }];\nif (mode === 'false') { return passthrough(true, 'disabled'); }\nconst secret = $env.META_APP_SECRET || '';\nconst headers = $input.first().json.headers || {};\nconst signature = headers['x-hub-signature-256'] || headers['X-Hub-Signature-256'] || '';\nconst rawBody = JSON.stringify($input.first().json.body);\nif (!secret) { return passthrough(false, 'missing_secret'); }\nconst run = async () => { try { const encoder = new TextEncoder(); const key = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); const sig = await crypto.subtle.sign('HMAC', key, encoder.encode(rawBody)); const hex = Array.from(new Uint8Array(sig)).map(b => b.toString(16).padStart(2, '0')).join(''); return passthrough(signature === 'sha256=' + hex, 'enabled'); } catch (e) { return passthrough(false, 'crypto_unavailable'); } };\nreturn run();"
},
"id": "n4",
"name": "Verificar Firma Meta",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-240,
368
]
},
{
"parameters": {
"conditions": [
{
"id": "1",
"leftValue": "={{ $json.verified ? 'true' : 'false' }}",
"rightValue": "true",
"operator": "string"
}
],
"options": {}
},
"id": "n5",
"name": "IF Firma OK",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
0,
368
]
},
{
"parameters": {
"respondWith": "text",
"responseBody": "unauthorized",
"options": {}
},
"id": "n6",
"name": "Responder Rechazo",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
240,
368
]
},
{
"parameters": {
"jsCode": "// Normaliza el payload; los eventos no-mensaje (status, tipos raros, propio negocio) terminan en silencio (return []).\nconst payload = $input.first().json;\nconst businessPhone = ($env.WHATSAPP_BUSINESS_PHONE || '').replace(/\\D/g, '');\nfor (const entry of payload.body.entry || payload.entry || []) {\n for (const change of entry.changes || []) {\n const value = change.value || {};\n if (value.messages && value.messages.length > 0) {\n const msg = value.messages[0];\n const from = String(msg.from || '').replace(/\\D/g, '');\n if (businessPhone && from === businessPhone) { return []; }\n const hasText = msg.text && msg.text.body;\n if (msg.type && msg.type !== 'text' && !hasText) { return []; }\n if (!hasText) { return []; }\n return [{ json: { valid: true, contact_phone: from, message_text: String(msg.text.body).slice(0, 2000), message_id: msg.id || '', timestamp: msg.timestamp || '', type: msg.type || 'text' } }];\n }\n if (value.statuses && value.statuses.length > 0) { return []; }\n }\n}\nreturn [];"
},
"id": "n7",
"name": "Normalizar Mensaje",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
240,
208
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SUPABASE_URL + '/rest/v1/contactos?on_conflict=tenant_id,telefono' }}",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "={{ $env.SUPABASE_SERVICE_KEY }}"
},
{
"name": "Authorization",
"value": "={{ \"Bearer \" + $env.SUPABASE_SERVICE_KEY }}"
},
{
"name": "Prefer",
"value": "resolution=merge-duplicates,return=representation"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ { telefono: $json.contact_phone, tenant_id: $env.TENANT_ID } }}",
"options": {},
"specifyHeaders": "keypair",
"contentType": "json"
},
"id": "n8",
"name": "Upsert Contacto",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
480,
208
]
},
{
"parameters": {
"jsCode": "// Encuentra la fila del contacto en cualquier forma de respuesta (array, body, anidado).\nconst findRow = (x) => {\n if (Array.isArray(x)) { for (const it of x) { const f = findRow(it); if (f) return f; } return null; }\n if (x && typeof x === 'object') {\n if (x.id && (x.telefono || x.tenant_id)) return x;\n for (const v of Object.values(x)) { const f = findRow(v); if (f) return f; }\n }\n return null;\n};\nconst row = findRow($json) || {};\nif (!row.id) { throw new Error('UPSERT-FALLO: ' + JSON.stringify($json).slice(0, 300)); }\nreturn [{ json: { contacto_id: row.id, tenant_id: row.tenant_id || null } }];"
},
"id": "n9",
"name": "Contacto Listo",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
720,
208
]
},
{
"parameters": {
"operation": "getAll",
"tableId": "conversaciones",
"returnAll": false,
"limit": 20,
"orderBy": "created_at.desc",
"filterType": "manual",
"matchType": "allFilters",
"filters": {
"conditions": [
{
"keyName": "contacto_id",
"condition": "eq",
"keyValue": "={{ $('Contacto Listo').item.json.contacto_id }}"
}
]
}
},
"id": "n10",
"name": "Obtener Historial",
"type": "n8n-nodes-base.supabase",
"typeVersion": 1,
"position": [
960,
80
]
},
{
"parameters": {
"tableId": "conversaciones",
"dataToSend": "defineBelow",
"fieldsUi": {
"fieldValues": [
{
"fieldId": "tenant_id",
"fieldValue": "={{ $('Contacto Listo').item.json.tenant_id }}"
},
{
"fieldId": "contacto_id",
"fieldValue": "={{ $('Contacto Listo').item.json.contacto_id }}"
},
{
"fieldId": "role",
"fieldValue": "user"
},
{
"fieldId": "content",
"fieldValue": "={{ $('Normalizar Mensaje').item.json.message_text }}"
}
]
}
},
"id": "n11",
"name": "Guardar Mensaje Usuario",
"type": "n8n-nodes-base.supabase",
"typeVersion": 1,
"position": [
960,
320
]
},
{
"parameters": {
"jsCode": "let historyRows = [];\ntry { historyRows = $('Obtener Historial').all().map(i => i.json).flat(1).filter(r => r && typeof r === 'object' && !Array.isArray(r)); } catch (e) {}\nconst contents = historyRows.map(h => ({ role: h.role === 'assistant' ? 'model' : 'user', parts: [{ text: h.content }] }));\ncontents.push({ role: 'user', parts: [{ text: $('Normalizar Mensaje').item.json.message_text }] });\nconst systemInstruction = $env.SYSTEM_PROMPT || \"Eres el asistente virtual de JeanCRG, una consultoria de software e inteligencia artificial en Colombia. Estilo: cercano y con energia, usas emojis con moderacion (\ud83d\udc4b \ud83d\ude80 \u2705 \ud83d\udcb0 \ud83d\udcf2) y formato de WhatsApp (*negrita* para precios y servicios, _cursiva_ para enfatizar). Respondes SIEMPRE en espanol neutro, en maximo 2-3 frases, y TERMINAS con una pregunta para mantener la conversacion. Nunca des listas largas: si preguntan por servicios, presenta 2-3 opciones y pregunta cual les interesa.\\n\\nSERVICIOS Y PRECIOS (no inventes otros): 1) *Asistente IA para WhatsApp* desde *$1.800.000* + retainer mensual de *$250.000* (montaje en 5 dias) 2) *Diagnostico tecnologico* por *$300.000* (descontable) 3) *Automatizaciones con n8n* desde *$1.500.000* 4) *Chatbot con datos propios (RAG)* desde *$2.500.000* 5) *Tienda online* desde *$2.500.000* 6) *Desarrollo a medida* desde *$3.000.000*. *Piloto de 15 dias por $800.000* (descontable del precio final).\\n\\nREGLA DE LEAD: si el cliente quiere agendar, recibir propuesta o cotizacion, responde amablemente y agrega al final de tu respuesta la marca exacta: [LEAD cita].\\nREGLA DE DERIVACION: si el cliente esta frustrado, insatisfecho o pide hablar con un humano, responde con empatia y agrega al final la marca exacta: [DERIVAR].\\nREGLA DE OPT-OUT: si el cliente escribe STOP o BAJA, confirma que no volvera a recibir mensajes y no insistas.\\nLas marcas [LEAD ...] y [DERIVAR] son internas: nunca las expliques al cliente, solo agregalas al final.\";\nreturn [{ json: { model: 'gemini-flash-latest', contents, system_instruction: { parts: [{ text: systemInstruction }] } } }];"
},
"id": "n12",
"name": "Preparar Contexto Gemini",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1200,
208
]
},
{
"parameters": {
"method": "POST",
"url": "https://generativelanguage.googleapis.com/v1beta/models/gemini-flash-latest:generateContent",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "x-goog-api-key",
"value": "={{ $env.GEMINI_API_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ $json }}",
"options": {},
"specifyHeaders": "keypair",
"contentType": "json"
},
"id": "n13",
"name": "Gemini Responder",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1440,
208
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000
},
{
"parameters": {
"jsCode": "const text = ($json.candidates && $json.candidates[0] && $json.candidates[0].content && $json.candidates[0].content.parts[0].text) || '';\nconst clean = text.replace(/\\[DERIVAR\\]/gi, '').replace(/\\[LEAD[^\\]]*\\]/gi, '').trim().slice(0, 4000);\nreturn [{ json: { raw: text, clean, needs_human: /\\[DERIVAR\\]/i.test(text), lead_marker: (text.match(/\\[LEAD ([^\\]]*)\\]/i) || [null, ''])[1] } }];"
},
"id": "n14",
"name": "Extraer Respuesta",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1680,
208
]
},
{
"parameters": {
"tableId": "conversaciones",
"dataToSend": "defineBelow",
"fieldsUi": {
"fieldValues": [
{
"fieldId": "tenant_id",
"fieldValue": "={{ $('Contacto Listo').item.json.tenant_id }}"
},
{
"fieldId": "contacto_id",
"fieldValue": "={{ $('Contacto Listo').item.json.contacto_id }}"
},
{
"fieldId": "role",
"fieldValue": "assistant"
},
{
"fieldId": "content",
"fieldValue": "={{ $json.clean }}"
}
]
}
},
"id": "n15",
"name": "Guardar Respuesta IA",
"type": "n8n-nodes-base.supabase",
"typeVersion": 1,
"position": [
1920,
208
]
},
{
"parameters": {
"operation": "send",
"phoneNumberId": "=1277072472156993",
"recipientPhoneNumber": "={{ $('Normalizar Mensaje').item.json.contact_phone }}",
"textBody": "={{ $('Extraer Respuesta').item.json.clean }}",
"additionalFields": {}
},
"id": "n17",
"name": "Enviar WhatsApp",
"type": "n8n-nodes-base.whatsApp",
"typeVersion": 1.1,
"position": [
2160,
320
]
},
{
"parameters": {
"jsCode": "// Emite es_lead + datos; sin throw (el insert se hace no-op con body [] cuando no hay lead).\nconst lead = $('Extraer Respuesta').item.json.lead_marker || '';\nconst parts = lead ? String(lead).split('|') : [];\nreturn [{ json: {\n es_lead: Boolean(lead),\n nombre: (parts[0] || '').trim(),\n telefono: $('Normalizar Mensaje').item.json.contact_phone,\n interes: (parts[2] || parts[0] || '').trim()\n} }];"
},
"id": "n18",
"name": "Preparar Lead",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2160,
120
]
},
{
"parameters": {
"method": "POST",
"url": "={{ $env.SUPABASE_URL + '/rest/v1/leads' }}",
"sendHeaders": true,
"specifyHeaders": "keypair",
"headerParameters": {
"parameters": [
{
"name": "apikey",
"value": "={{ $env.SUPABASE_SERVICE_KEY }}"
},
{
"name": "Authorization",
"value": "={{ \"Bearer \" + $env.SUPABASE_SERVICE_KEY }}"
}
]
},
"sendBody": true,
"specifyBody": "json",
"contentType": "json",
"jsonBody": "={{ $json.es_lead ? [{ tenant_id: $('Contacto Listo').item.json.tenant_id, contacto_id: $('Contacto Listo').item.json.contacto_id, nombre: $json.nombre, telefono: $json.telefono, interes: $json.interes, estado: 'nuevo', source: 'whatsapp' }] : [] }}",
"options": {}
},
"id": "n19b",
"name": "Guardar Lead",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2400,
120
]
},
{
"parameters": {
"jsCode": "// Solo notifica cuando Gemini marco [DERIVAR].\nconst extra = $('Extraer Respuesta').item.json;\nconst notificar = Boolean(extra.needs_human);\nconst texto = notificar ? 'DERIVAR: contacto ' + $('Normalizar Mensaje').item.json.contact_phone + ' | ' + extra.clean : '';\nreturn [{ json: { notificar, texto } }];"
},
"id": "n20",
"name": "Preparar Notificacion",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2640,
80
]
},
{
"parameters": {
"method": "POST",
"url": "={{ 'https://api.telegram.org/bot8807842110:AAGgU6QDADaN-Lvt_hqZV_orFIhG0dwrT1E/sendMessage' }}",
"sendBody": true,
"specifyBody": "json",
"contentType": "json",
"jsonBody": "={{ $json.notificar ? { chat_id: 7309831214, text: $json.texto } : {} }}",
"options": {}
},
"id": "n21",
"name": "Enviar Notificacion",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2880,
80
],
"onError": "continueRegularOutput",
"notes": "DEMO: token del bot embebido. PRODUCCION: mover a $env.TELEGRAM_BOT_TOKEN en Railway."
}
],
"connections": {
"Webhook": {
"main": [
[
{
"node": "IF Verificaci\u00f3n",
"type": "main",
"index": 0
}
],
[
{
"node": "Verificar Firma Meta",
"type": "main",
"index": 0
}
]
]
},
"IF Verificaci\u00f3n": {
"main": [
[
{
"node": "Responder Challenge",
"type": "main",
"index": 0
}
],
[
{
"node": "Verificar Firma Meta",
"type": "main",
"index": 0
}
]
]
},
"Verificar Firma Meta": {
"main": [
[
{
"node": "IF Firma OK",
"type": "main",
"index": 0
}
]
]
},
"IF Firma OK": {
"main": [
[
{
"node": "Normalizar Mensaje",
"type": "main",
"index": 0
}
],
[
{
"node": "Responder Rechazo",
"type": "main",
"index": 0
}
]
]
},
"Normalizar Mensaje": {
"main": [
[
{
"node": "Upsert Contacto",
"type": "main",
"index": 0
}
]
]
},
"Upsert Contacto": {
"main": [
[
{
"node": "Contacto Listo",
"type": "main",
"index": 0
}
]
]
},
"Contacto Listo": {
"main": [
[
{
"node": "Obtener Historial",
"type": "main",
"index": 0
},
{
"node": "Guardar Mensaje Usuario",
"type": "main",
"index": 0
}
]
]
},
"Guardar Mensaje Usuario": {
"main": [
[
{
"node": "Preparar Contexto Gemini",
"type": "main",
"index": 0
}
]
]
},
"Preparar Contexto Gemini": {
"main": [
[
{
"node": "Gemini Responder",
"type": "main",
"index": 0
}
]
]
},
"Gemini Responder": {
"main": [
[
{
"node": "Extraer Respuesta",
"type": "main",
"index": 0
}
]
]
},
"Extraer Respuesta": {
"main": [
[
{
"node": "Guardar Respuesta IA",
"type": "main",
"index": 0
}
]
]
},
"Guardar Respuesta IA": {
"main": [
[
{
"node": "Preparar Lead",
"type": "main",
"index": 0
}
]
]
},
"Preparar Lead": {
"main": [
[
{
"node": "Guardar Lead",
"type": "main",
"index": 0
}
]
]
},
"Guardar Lead": {
"main": [
[
{
"node": "Preparar Notificacion",
"type": "main",
"index": 0
}
]
]
},
"Preparar Notificacion": {
"main": [
[
{
"node": "Enviar Notificacion",
"type": "main",
"index": 0
}
]
]
},
"Enviar Notificacion": {
"main": [
[
{
"node": "Enviar WhatsApp",
"type": "main",
"index": 0
}
]
]
}
},
"settings": {
"executionOrder": "v1",
"binaryMode": "separate",
"availableInMCP": false
}
}
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
WhatsApp Assistant Base - P1. Uses httpRequest, supabase, whatsApp. Webhook trigger; 20 nodes.
Source: https://github.com/JeanCardozo/chatAI/blob/main/workflows/whatsapp-assistant-v4-FINAL.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 Assistant Base — P1. Uses supabase, httpRequest, telegram, whatsApp. Webhook trigger; 22 nodes.
WhatsApp Assistant Base - P1. Uses httpRequest, supabase, whatsApp. Webhook trigger; 20 nodes.
Course Bot - Didar CRM (v6). Uses n8n-nodes-didar-crm, httpRequest. Webhook trigger; 77 nodes.
Advanced Slackbot With N8N. Uses slack, httpRequest, stickyNote, executeWorkflow. Webhook trigger; 34 nodes.
Slackbots are super powerful. At n8n, we have been using them to get a lot done.. But it can become hard to manage and maintain many different operations that a workflow can do.