{
  "name": "WhatsApp customer service (WAIA Connect)",
  "nodes": [
    {
      "parameters": {
        "content": "## WhatsApp customer service on n8n (WAIA Connect)\n**ES · Empezá acá — 5 pasos**\n1. **Tablas:** abajo a la izquierda, apretá ▶ en *Run once: create tables* (crea 3 Data tables).\n2. **Credenciales** (nada de secretos en el workflow):\n   · *Connect webhook token* → tipo **Header Auth**, Name `X-Connect-Token`, Value tu token `wct_…`.\n   · *Connect API key* → tipo **Header Auth**, Name `Authorization`, Value `Bearer wc_live_…`.\n   · *OpenAI* (o conectá el nodo de Claude) y, si avisás por correo, *SMTP*.\n3. **Config:** escribí tu negocio en `businessInstructions` (qué vendés, horarios, tono), tu horario, tu correo. Pegá tu `whsec_…` en `signingSecret` para verificar la firma.\n4. **Activá** el workflow y copiá la *Production URL* del nodo *Connect webhook*.\n5. En Connect → **Webhooks → Agregar endpoint**: esa URL, eventos `message.received`, `message.echo`, `message.status`, y generá el token estático (`wct_…`). Apretá **Probar**.\n\n**EN · Start here — 5 steps**\n1. **Tables:** bottom-left, press ▶ on *Run once: create tables* (creates 3 Data tables).\n2. **Credentials** (no secrets inside the workflow): *Connect webhook token* (Header Auth, `X-Connect-Token` = your `wct_…`), *Connect API key* (Header Auth, `Authorization` = `Bearer wc_live_…`), *OpenAI* (or wire the Claude node) and *SMTP* if you alert by email.\n3. **Config:** describe your business in `businessInstructions`, set your hours and email. Paste your `whsec_…` in `signingSecret` to verify signatures.\n4. **Activate** and copy the *Production URL* of *Connect webhook*.\n5. In Connect → **Webhooks → Add endpoint**: that URL, events `message.received`, `message.echo`, `message.status`, and generate the static token. Press **Test**.\n\nTested on n8n 2.40.7 against a fake Connect. Needs n8n with **Data tables** (2.x).",
        "height": 900,
        "width": 560
      },
      "id": "wacs-01",
      "name": "Start here / Empezá acá",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -1180,
        -420
      ]
    },
    {
      "parameters": {
        "content": "## What it does / Qué hace\n- **Verifies** Connect's signature (HMAC) + header token · **Verifica** firma y token.\n- **Never twice:** dedupe by WhatsApp message id (Connect retries, and Meta re-delivers) · **Nunca dos veces**.\n- **AI reply** with your business instructions and **memory per contact** · **Responde con IA**.\n- **Hand-off to a person** when asked or when the AI doesn't know → alerts you and **goes quiet** in that chat.\n- 🔑 **You reply from your phone (Coexistence) → the bot goes quiet** in that chat for a while (`message.echo`).\n- **Business hours:** outside them, one message and nothing else.\n- **Audio/images:** an honest reply, not silence.\n- **Opt-out:** «baja» / «stop» → never writes again (until «alta» / «start»).\n- **Errors:** a rejected send is logged and you get an alert — **never a retry loop**.",
        "height": 420,
        "width": 620,
        "color": 4
      },
      "id": "wacs-02",
      "name": "What it does / Qué hace",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        -560,
        -420
      ]
    },
    {
      "parameters": {
        "content": "### Swap the model / Cambiar el modelo\n**ES:** para usar Claude: activá *Claude model (alternative)* (viene desactivado), elegí tu credencial de Anthropic, borrá la conexión de *OpenAI model* al agente y conectá el de Claude. Cualquier otro modelo de chat de n8n sirve igual.\n\n**EN:** to use Claude: activate *Claude model (alternative)* (it ships deactivated), pick your Anthropic credential, remove the *OpenAI model* link to the agent and connect Claude instead. Any other n8n chat model works the same way.",
        "height": 260,
        "width": 420,
        "color": 6
      },
      "id": "wacs-03",
      "name": "Swap the model / Cambiar el modelo",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1780,
        700
      ]
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "waia-connect-whatsapp",
        "authentication": "headerAuth",
        "responseMode": "responseNode",
        "options": {
          "rawBody": true
        }
      },
      "id": "wacs-04",
      "name": "Connect webhook",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        -540,
        200
      ],
      "notes": "ES: Recibe cada evento de WAIA Connect. POST (n8n crea los webhooks en GET por defecto y Connect entrega por POST). Header Auth con la credencial «Connect webhook token» (X-Connect-Token = tu wct_…): n8n rechaza con 403 lo que no la trae, antes de correr nada. «Raw Body» activado para verificar la firma sobre los bytes exactos.\n\nEN: Receives every WAIA Connect event. POST (n8n creates webhooks as GET by default; Connect delivers POST). Header Auth with the «Connect webhook token» credential (X-Connect-Token = your wct_…): n8n answers 403 to anything without it, before running anything. «Raw Body» on, to verify the signature over the exact bytes.",
      "webhookId": "waia-connect-whatsapp-customer-service",
      "credentials": {
        "httpHeaderAuth": {
          "id": "",
          "name": "Connect webhook token"
        }
      }
    },
    {
      "parameters": {
        "jsCode": "// Keep the exact bytes Connect signed. The signature is over `${timestamp}.${rawBody}`:\n// re-serialising the parsed JSON could change it.\n// Guardamos los bytes exactos que firmó Connect.\nconst item = $input.first();\nlet rawBody = '';\ntry {\n  rawBody = (await this.helpers.getBinaryDataBuffer(0, 'data')).toString('utf8');\n} catch (e) {\n  rawBody = JSON.stringify(item.json.body); // fallback: Connect's own JSON.stringify round-trips\n}\nreturn [{ json: { ...item.json, rawBody } }];"
      },
      "id": "wacs-05",
      "name": "Read raw body",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        -320,
        200
      ],
      "notes": "ES: Toma el cuerpo crudo (los bytes que firmó Connect). Sin esto la firma no se puede verificar: volver a serializar el JSON puede cambiar un byte.\n\nEN: Takes the raw body (the bytes Connect signed). Without it the signature can't be checked: re-serialising the JSON may change a byte."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "c01",
              "name": "businessInstructions",
              "type": "string",
              "value": "Sos el asistente de WhatsApp de **Panadería La Espiga** (Córdoba).\nQué vendemos: pan, facturas, tortas por encargo (con 48 h de anticipación).\nHorario: lunes a viernes de 8 a 20, sábados de 8 a 13. Domingos cerrado.\nDirección: Av. Siempre Viva 123. Envíos: sólo dentro de Nueva Córdoba, $2.000.\nPrecios: no los inventes; si te preguntan uno que no está acá, pasá a una persona.\nTono: cálido, breve, de vos. Nada de emojis salvo uno al saludar."
            },
            {
              "id": "c02",
              "name": "timezone",
              "type": "string",
              "value": "America/Argentina/Cordoba"
            },
            {
              "id": "c03",
              "name": "openingHours",
              "type": "string",
              "value": "{\"mon\":\"08:00-20:00\",\"tue\":\"08:00-20:00\",\"wed\":\"08:00-20:00\",\"thu\":\"08:00-20:00\",\"fri\":\"08:00-20:00\",\"sat\":\"08:00-13:00\",\"sun\":\"\"}"
            },
            {
              "id": "c04",
              "name": "ownerReplySilenceMinutes",
              "type": "number",
              "value": 120
            },
            {
              "id": "c05",
              "name": "handoffSilenceHours",
              "type": "number",
              "value": 24
            },
            {
              "id": "c06",
              "name": "closedNoticeEveryHours",
              "type": "number",
              "value": 12
            },
            {
              "id": "c07",
              "name": "closedText",
              "type": "string",
              "value": "¡Hola! Ahora estamos cerrados. Te respondemos apenas abramos (lunes a viernes de 8 a 20, sábados de 8 a 13)."
            },
            {
              "id": "c08",
              "name": "mediaText",
              "type": "string",
              "value": "Recibí tu {kind}, pero soy un asistente automático y por ahora sólo leo texto. ¿Me lo escribís? Si preferís hablar con una persona, escribí PERSONA."
            },
            {
              "id": "c09",
              "name": "handoffText",
              "type": "string",
              "value": "Te paso con una persona del equipo. Te escribe por acá apenas pueda."
            },
            {
              "id": "c10",
              "name": "optOutText",
              "type": "string",
              "value": "Listo, no te vamos a escribir más. Si cambiás de idea, escribí ALTA."
            },
            {
              "id": "c11",
              "name": "optInText",
              "type": "string",
              "value": "¡Bienvenido de vuelta! ¿En qué te ayudo?"
            },
            {
              "id": "c12",
              "name": "optOutWords",
              "type": "string",
              "value": "baja, stop, unsubscribe, parar, no me escriban más"
            },
            {
              "id": "c13",
              "name": "optInWords",
              "type": "string",
              "value": "alta, start"
            },
            {
              "id": "c14",
              "name": "humanWords",
              "type": "string",
              "value": "persona, humano, asesor, operador, agente, human, person, agent"
            },
            {
              "id": "c15",
              "name": "ownerAlert",
              "type": "string",
              "value": "email"
            },
            {
              "id": "c16",
              "name": "ownerEmail",
              "type": "string",
              "value": "you@example.com"
            },
            {
              "id": "c17",
              "name": "fromEmail",
              "type": "string",
              "value": "bot@example.com"
            },
            {
              "id": "c18",
              "name": "ownerWhatsapp",
              "type": "string",
              "value": ""
            },
            {
              "id": "c19",
              "name": "ownerTemplateName",
              "type": "string",
              "value": "aviso_al_dueno"
            },
            {
              "id": "c20",
              "name": "ownerTemplateLanguage",
              "type": "string",
              "value": "es_AR"
            },
            {
              "id": "c21",
              "name": "signingSecret",
              "type": "string",
              "value": "whsec_PASTE_YOURS"
            },
            {
              "id": "c22",
              "name": "apiBase",
              "type": "string",
              "value": "https://api.waiaconnect.com"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "wacs-06",
      "name": "Config",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        -100,
        200
      ],
      "notes": "ES: TODO lo que se configura está acá, en un solo lugar: las instrucciones del negocio (qué vendés, horarios, tono — el modelo las lee tal cual), el horario de atención (JSON por día, zona horaria), cuánto se calla el bot si contestás desde el celular (ownerReplySilenceMinutes) o si pasa a una persona (handoffSilenceHours), los textos fijos, las palabras de baja/alta/persona, y cómo te avisa (ownerAlert: email | whatsapp_template | none). ⚠ signingSecret: tu whsec_ del endpoint; con el valor de ejemplo la firma NO se verifica (queda sólo el token del encabezado). n8n no deja que un nodo lea una credencial, por eso este secreto vive acá: si exportás o compartís el workflow, borralo antes.\n\nEN: EVERYTHING you configure lives here, in one place: your business instructions (what you sell, hours, tone — the model reads them as-is), opening hours (JSON per day, time zone), how long the bot stays quiet after you reply from your phone (ownerReplySilenceMinutes) or after a hand-off (handoffSilenceHours), the fixed texts, the opt-out/opt-in/human words, and how you get alerted (ownerAlert: email | whatsapp_template | none). ⚠ signingSecret: your endpoint's whsec_; with the sample value the signature is NOT verified (only the header token is). n8n doesn't let a node read a credential, so this secret lives here: remove it before exporting or sharing the workflow."
    },
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{ $json.headers['x-connect-timestamp'] }}.{{ $json.rawBody }}",
        "dataPropertyName": "expectedSignature",
        "secret": "={{ $json.signingSecret }}",
        "encoding": "hex"
      },
      "id": "wacs-07",
      "name": "HMAC of the body",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        120,
        200
      ],
      "notes": "ES: Calcula el HMAC-SHA256 de «timestamp.cuerpo» con tu signingSecret — es exactamente lo que firma Connect (X-Connect-Signature-256). Se usa el nodo Crypto y no require('crypto'): en un n8n propio el nodo Code no puede importar crypto por defecto.\n\nEN: Computes HMAC-SHA256 of «timestamp.body» with your signingSecret — exactly what Connect signs (X-Connect-Signature-256). It uses the Crypto node, not require('crypto'): on a self-hosted n8n the Code node can't import crypto by default."
    },
    {
      "parameters": {
        "jsCode": "// Compare in constant time + reject anything older than 5 minutes (anti-replay).\n// Comparación en tiempo constante + rechazo de lo que tenga más de 5 minutos.\nconst j = $input.first().json;\nconst secret = String(j.signingSecret || '');\nif (!secret || secret === 'whsec_PASTE_YOURS') {\n  // Not configured: only the header token (Header Auth) protects the door.\n  return [{ json: { ...j, signatureOk: true, signatureCheck: 'skipped (no signingSecret)' } }];\n}\nconst got = String(j.headers['x-connect-signature-256'] || '');\nconst want = 'sha256=' + j.expectedSignature;\nlet diff = got.length ^ want.length;\nfor (let i = 0; i < Math.max(got.length, want.length); i++) {\n  diff |= (got.charCodeAt(i) || 0) ^ (want.charCodeAt(i) || 0);\n}\nconst ts = Number(j.headers['x-connect-timestamp']);\nconst fresh = Number.isFinite(ts) && Math.abs(Date.now() / 1000 - ts) <= 300;\nreturn [{ json: { ...j, signatureOk: diff === 0 && fresh, signatureCheck: diff !== 0 ? 'bad signature' : fresh ? 'ok' : 'timestamp older than 5 min' } }];"
      },
      "id": "wacs-08",
      "name": "Check signature",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        340,
        200
      ],
      "notes": "ES: Compara la firma que mandó Connect con la calculada, en tiempo constante, y rechaza entregas de más de 5 minutos (anti-replay: el timestamp va DENTRO de lo firmado). Sin signingSecret configurado, deja pasar (el token del encabezado ya lo comprobó n8n).\n\nEN: Compares Connect's signature with the computed one in constant time, and rejects deliveries older than 5 minutes (anti-replay: the timestamp is INSIDE what's signed). With no signingSecret set, it lets it through (n8n already checked the header token)."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "sig",
              "leftValue": "={{ $json.signatureOk }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "wacs-09",
      "name": "Signature OK?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        560,
        200
      ],
      "notes": "ES: Firma válida → sigue. Inválida → 401 y nada más.\n\nEN: Valid signature → go on. Invalid → 401 and nothing else."
    },
    {
      "parameters": {
        "respondWith": "noData",
        "options": {
          "responseCode": 401
        }
      },
      "id": "wacs-10",
      "name": "Reject (401)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        780,
        380
      ],
      "notes": "ES: Contesta 401 a una entrega con la firma mal o vieja. Connect la cuenta como fallo y la reintenta; si la firma sigue mal (secreto equivocado), tras 20 fallos seguidos pausa el endpoint y te avisa por correo.\n\nEN: Answers 401 to a delivery with a bad or stale signature. Connect counts it as a failure and retries; if the signature keeps failing (wrong secret), after 20 failures in a row it pauses the endpoint and emails you."
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "ok",
        "options": {
          "responseCode": 200
        }
      },
      "id": "wacs-11",
      "name": "Accept (200)",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        780,
        200
      ],
      "notes": "ES: Contesta 200 enseguida, ANTES de la IA. Connect espera 10 s: si la IA tarda más, Connect reintentaría el mismo evento. Respondiendo primero no hay reintento; y si igual llega uno, el paso «Seen before?» lo frena.\n\nEN: Answers 200 right away, BEFORE the AI. Connect waits 10 s: if the AI took longer, Connect would retry the same event. Answering first avoids the retry; and if one arrives anyway, «Seen before?» stops it."
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.body.type }}",
                    "rightValue": "message.received",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "message.received"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.body.type }}",
                    "rightValue": "message.echo",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "message.echo"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.body.type }}",
                    "rightValue": "message.status",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "message.status"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "other (webhook.test, …)"
        }
      },
      "id": "wacs-12",
      "name": "Event type",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        1000,
        200
      ],
      "notes": "ES: Separa por tipo de evento (los nombres son los de Connect): message.received = te escribió un cliente · message.echo = VOS contestaste desde el celular (convivencia; sólo llega lo que se manda desde la app, no lo que manda este bot por la API) · message.status = acuse de un envío (acá sólo importan los que fallaron). El resto (webhook.test del botón Probar, eventos de cuenta) no hace nada.\n\nEN: Splits by event type (Connect's own names): message.received = a customer wrote · message.echo = YOU replied from the phone (Coexistence; only what is sent from the app arrives, not what this bot sends through the API) · message.status = a send receipt (only failures matter here). Everything else (webhook.test from the Test button, account events) does nothing."
    },
    {
      "parameters": {},
      "id": "wacs-13",
      "name": "Other events: nothing to do",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1220,
        560
      ],
      "notes": "ES: webhook.test (el botón Probar del panel) y los eventos de cuenta llegan acá y se ignoran. Ya contestamos 200: Probar da verde.\n\nEN: webhook.test (the panel's Test button) and account events land here and are ignored. We already answered 200: Test goes green."
    },
    {
      "parameters": {
        "jsCode": "// Connect's envelope: body.data.message is Meta's message as-is, body.data.contacts its contacts.\n// El sobre de Connect: body.data.message es el mensaje de Meta tal cual.\nconst b = $input.first().json.body;\nconst m = (b.data && b.data.message) || {};\nconst contact = (b.data && b.data.contacts && b.data.contacts[0]) || {};\nlet text = '';\nif (m.type === 'text') text = (m.text && m.text.body) || '';\nelse if (m.type === 'button') text = (m.button && m.button.text) || '';\nelse if (m.type === 'interactive') {\n  const i = m.interactive || {};\n  text = (i.button_reply && i.button_reply.title) || (i.list_reply && i.list_reply.title) || '';\n}\nreturn [{ json: {\n  eventId: b.id,\n  wamid: m.id || b.id,\n  dedupeKey: 'in:' + (m.id || b.id),\n  connectionId: b.connection && b.connection.id,\n  phone: String(m.from || '').replace(/\\D/g, ''),\n  name: (contact.profile && contact.profile.name) || '',\n  kind: m.type || 'unknown',\n  text: String(text).trim(),\n} }];"
      },
      "id": "wacs-14",
      "name": "Normalize message",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1220,
        0
      ],
      "notes": "ES: Saca lo que importa del sobre de Connect: quién escribe (from), qué tipo de mensaje es, el texto (también de botones y listas), el id de WhatsApp (wamid) y la conexión (conn_…) por la que hay que contestar.\n\nEN: Pulls what matters out of Connect's envelope: who wrote (from), the message type, the text (also from buttons and lists), the WhatsApp id (wamid) and the connection (conn_…) to reply through."
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "rowNotExists",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "connect_bot_seen"
        },
        "matchType": "anyCondition",
        "filters": {
          "conditions": [
            {
              "keyName": "key",
              "condition": "eq",
              "keyValue": "={{ $json.dedupeKey }}"
            }
          ]
        }
      },
      "id": "wacs-15",
      "name": "Seen before?",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        1440,
        0
      ],
      "notes": "ES: Deja pasar SÓLO lo que no se procesó antes, por el id del mensaje de WhatsApp (wamid). Dos motivos para que llegue repetido: Connect reintenta si no le contestamos 2xx (mismo evento), y Meta a veces reentrega el mismo mensaje (Connect lo reenvía con OTRO id de evento, pero el mismo wamid). Por eso la clave es el wamid.\n\nEN: Lets through ONLY what wasn't processed before, keyed by the WhatsApp message id (wamid). Two reasons it can arrive twice: Connect retries when it doesn't get a 2xx (same event), and Meta sometimes re-delivers the same message (Connect forwards it with ANOTHER event id but the same wamid). That's why the key is the wamid."
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "connect_bot_seen"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "key": "={{ $json.dedupeKey }}",
            "at": "={{ Date.now() }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "key",
              "displayName": "key",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "at",
              "displayName": "at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "readOnly": false,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "wacs-16",
      "name": "Mark as seen",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        1660,
        0
      ],
      "notes": "ES: Anota el wamid como procesado. Aunque dos ejecuciones se crucen, la respuesta no sale dos veces: el envío lleva un Idempotency-Key atado al wamid y Connect devuelve el mismo mensaje en vez de mandar otro.\n\nEN: Records the wamid as processed. Even if two executions race, the reply can't go out twice: the send carries an Idempotency-Key tied to the wamid, and Connect returns the same message instead of sending another."
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "get",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "connect_bot_contacts"
        },
        "matchType": "anyCondition",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "condition": "eq",
              "keyValue": "={{ $('Normalize message').first().json.phone }}"
            }
          ]
        },
        "returnAll": false,
        "limit": 1
      },
      "id": "wacs-17",
      "name": "Contact state",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        1880,
        0
      ],
      "notes": "ES: Lee lo que el bot sabe de este contacto: si pidió la baja, si está callado (y hasta cuándo, y por qué), cuándo se le avisó que estaba cerrado, y lo último que le contestaste vos desde el celular.\n\nEN: Reads what the bot knows about this contact: opted out or not, whether it's quiet (until when, and why), when it was last told you're closed, and the last thing YOU replied from your phone.",
      "alwaysOutputData": true
    },
    {
      "parameters": {
        "jsCode": "// One place decides what to do with this message. Order matters.\n// Un solo lugar decide qué hacer. El orden importa.\nconst cfg = $('Config').first().json;\nconst msg = $('Normalize message').first().json;\nconst c = $input.first().json || {};\nconst now = Date.now();\nconst words = s => String(s || '').split(',').map(w => w.trim().toLowerCase()).filter(Boolean);\nconst norm = s => String(s || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/[¡!¿?.,]/g, '').trim();\nconst t = norm(msg.text);\nconst isOnly = list => words(list).some(w => t === norm(w));\nconst hasWord = list => words(list).some(w => new RegExp('(^|\\\\s)' + norm(w) + '(\\\\s|$)').test(t));\n\n// Opening hours in the business time zone (Luxon is built into n8n).\nconst local = DateTime.now().setZone(cfg.timezone);\nconst day = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'][local.weekday - 1];\nlet hours = {};\ntry { hours = JSON.parse(cfg.openingHours); } catch (e) { hours = {}; }\nconst hhmm = local.toFormat('HH:mm');\nconst open = String(hours[day] || '').split(',').some(r => {\n  const [a, b] = r.trim().split('-');\n  return a && b && hhmm >= a.trim() && hhmm < b.trim();\n});\n\nlet action = 'ai', reason = '';\nconst optedOut = c.optedOut === true || c.optedOut === 'true';\nconst silencedUntil = Number(c.silencedUntil || 0);\nif (optedOut && isOnly(cfg.optInWords)) action = 'optin';\nelse if (optedOut) { action = 'quiet'; reason = 'opted out'; }\nelse if (isOnly(cfg.optOutWords)) action = 'optout';\nelse if (silencedUntil > now) { action = 'quiet'; reason = 'silenced: ' + (c.silenceReason || '?') + ' until ' + new Date(silencedUntil).toISOString(); }\nelse if (!open) {\n  const last = Number(c.closedNoticeAt || 0);\n  if (now - last > Number(cfg.closedNoticeEveryHours) * 3600e3) action = 'closed';\n  else { action = 'quiet'; reason = 'closed, already told'; }\n}\nelse if (msg.kind === 'reaction') { action = 'quiet'; reason = 'reaction'; }\nelse if (!msg.text) action = 'media';\nelse if (hasWord(cfg.humanWords)) action = 'handoff';\n\nconst kindName = { audio: 'audio', voice: 'audio', image: 'imagen', video: 'video', document: 'documento', sticker: 'sticker', location: 'ubicación', contacts: 'contacto' }[msg.kind] || 'mensaje';\nconst ownerNote = c.ownerNote ? String(c.ownerNote) : '';\nconst aiInput = msg.text + (ownerNote\n  ? '\\n\\n[Contexto: hace poco una persona del negocio le escribió a este cliente desde el celular: \"' + ownerNote + '\"]'\n  : '');\nreturn [{ json: { ...msg, action, reason, open, localTime: local.toFormat(\"cccc dd/LL HH:mm\"), mediaKind: kindName, aiInput } }];"
      },
      "id": "wacs-18",
      "name": "Decide",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2100,
        0
      ],
      "notes": "ES: El cerebro, en un solo nodo y en este orden: ① «alta» de alguien dado de baja → vuelve · ② dado de baja → callado · ③ «baja»/«stop» → se da de baja · ④ callado (contestaste vos desde el celular, o se pasó a una persona) → callado · ⑤ fuera de horario → UN aviso cada closedNoticeEveryHours y nada más · ⑥ audio, imagen, etc. → respuesta honesta · ⑦ pide una persona → pase · ⑧ si no, la IA. El horario se calcula en la zona horaria de Config.\n\nEN: The brain, in one node and in this order: ① «alta/start» from an opted-out contact → back in · ② opted out → quiet · ③ «baja»/«stop» → opt out · ④ silenced (you replied from the phone, or it was handed off) → quiet · ⑤ outside opening hours → ONE notice per closedNoticeEveryHours and nothing else · ⑥ audio, image, etc. → an honest reply · ⑦ asks for a person → hand-off · ⑧ otherwise, the AI. Hours are computed in Config's time zone."
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "optout",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "optout"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "optin",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "optin"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "closed",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "closed"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "media",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "media"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "handoff",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "handoff"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "ai",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "ai"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $json.action }}",
                    "rightValue": "quiet",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "quiet"
            }
          ]
        },
        "options": {}
      },
      "id": "wacs-19",
      "name": "Route",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        2320,
        0
      ],
      "notes": "ES: Manda cada mensaje por su camino, según lo que decidió «Decide».\n\nEN: Sends each message down its path, according to «Decide»."
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "connect_bot_contacts"
        },
        "matchType": "anyCondition",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "condition": "eq",
              "keyValue": "={{ $('Decide').first().json.phone }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "phone": "={{ $('Decide').first().json.phone }}",
            "optedOut": "={{ true }}",
            "silenceReason": "opted out"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "optedOut",
              "displayName": "optedOut",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "boolean",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "silenceReason",
              "displayName": "silenceReason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "wacs-20",
      "name": "Save opt-out",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        2560,
        -420
      ],
      "notes": "ES: Guarda la baja. Desde ahora el bot no le contesta nada a este contacto (salvo «alta»/«start»).\n\nEN: Saves the opt-out. From now on the bot answers nothing to this contact (except «alta»/«start»)."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "to",
              "type": "string",
              "value": "={{ $('Decide').first().json.phone }}"
            },
            {
              "id": "r2",
              "name": "connectionId",
              "type": "string",
              "value": "={{ $('Decide').first().json.connectionId }}"
            },
            {
              "id": "r3",
              "name": "text",
              "type": "string",
              "value": "={{ $('Config').first().json.optOutText }}"
            },
            {
              "id": "r4",
              "name": "idempotencyKey",
              "type": "string",
              "value": "={{ 'optout-' + $('Decide').first().json.wamid }}"
            }
          ]
        },
        "options": {}
      },
      "id": "wacs-21",
      "name": "Reply: opt-out",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2780,
        -420
      ],
      "notes": "ES: Confirma la baja, una sola vez. Es una respuesta a su mensaje: está dentro de la ventana de 24 h.\n\nEN: Confirms the opt-out, once. It answers their message: inside the 24-hour window."
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "connect_bot_contacts"
        },
        "matchType": "anyCondition",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "condition": "eq",
              "keyValue": "={{ $('Decide').first().json.phone }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "phone": "={{ $('Decide').first().json.phone }}",
            "optedOut": "={{ false }}",
            "silenceReason": ""
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "optedOut",
              "displayName": "optedOut",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "boolean",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "silenceReason",
              "displayName": "silenceReason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "wacs-22",
      "name": "Save opt-in",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        2560,
        -280
      ],
      "notes": "ES: Borra la baja: el contacto pidió volver.\n\nEN: Clears the opt-out: the contact asked to come back."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "to",
              "type": "string",
              "value": "={{ $('Decide').first().json.phone }}"
            },
            {
              "id": "r2",
              "name": "connectionId",
              "type": "string",
              "value": "={{ $('Decide').first().json.connectionId }}"
            },
            {
              "id": "r3",
              "name": "text",
              "type": "string",
              "value": "={{ $('Config').first().json.optInText }}"
            },
            {
              "id": "r4",
              "name": "idempotencyKey",
              "type": "string",
              "value": "={{ 'optin-' + $('Decide').first().json.wamid }}"
            }
          ]
        },
        "options": {}
      },
      "id": "wacs-23",
      "name": "Reply: opt-in",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2780,
        -280
      ],
      "notes": "ES: Le da la bienvenida de vuelta.\n\nEN: Welcomes them back."
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "connect_bot_contacts"
        },
        "matchType": "anyCondition",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "condition": "eq",
              "keyValue": "={{ $('Decide').first().json.phone }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "phone": "={{ $('Decide').first().json.phone }}",
            "closedNoticeAt": "={{ Date.now() }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "closedNoticeAt",
              "displayName": "closedNoticeAt",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "readOnly": false,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "wacs-24",
      "name": "Save closed notice",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        2560,
        -140
      ],
      "notes": "ES: Anota cuándo se le avisó que estaba cerrado, para no repetirlo en cada mensaje.\n\nEN: Records when they were told you're closed, so it isn't repeated on every message."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "to",
              "type": "string",
              "value": "={{ $('Decide').first().json.phone }}"
            },
            {
              "id": "r2",
              "name": "connectionId",
              "type": "string",
              "value": "={{ $('Decide').first().json.connectionId }}"
            },
            {
              "id": "r3",
              "name": "text",
              "type": "string",
              "value": "={{ $('Config').first().json.closedText }}"
            },
            {
              "id": "r4",
              "name": "idempotencyKey",
              "type": "string",
              "value": "={{ 'closed-' + $('Decide').first().json.wamid }}"
            }
          ]
        },
        "options": {}
      },
      "id": "wacs-25",
      "name": "Reply: closed",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2780,
        -140
      ],
      "notes": "ES: El único mensaje fuera de horario. Después, silencio hasta que abras (o pasen closedNoticeEveryHours).\n\nEN: The only out-of-hours message. After that, silence until you open (or closedNoticeEveryHours pass)."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "to",
              "type": "string",
              "value": "={{ $('Decide').first().json.phone }}"
            },
            {
              "id": "r2",
              "name": "connectionId",
              "type": "string",
              "value": "={{ $('Decide').first().json.connectionId }}"
            },
            {
              "id": "r3",
              "name": "text",
              "type": "string",
              "value": "={{ $('Config').first().json.mediaText.replace('{kind}', $('Decide').first().json.mediaKind) }}"
            },
            {
              "id": "r4",
              "name": "idempotencyKey",
              "type": "string",
              "value": "={{ 'media-' + $('Decide').first().json.wamid }}"
            }
          ]
        },
        "options": {}
      },
      "id": "wacs-26",
      "name": "Reply: media",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2780,
        0
      ],
      "notes": "ES: Audio, imagen, video, documento, sticker, ubicación: una respuesta honesta en vez de silencio. El texto está en Config (mediaText). Si querés que la IA vea imágenes o transcriba audios, bajá el archivo con GET /v1/media/{id} de Connect — no viene incluido.\n\nEN: Audio, image, video, document, sticker, location: an honest reply instead of silence. The text is in Config (mediaText). To let the AI see images or transcribe audio, download the file with Connect's GET /v1/media/{id} — not included."
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "connect_bot_contacts"
        },
        "matchType": "anyCondition",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "condition": "eq",
              "keyValue": "={{ $('Decide').first().json.phone }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "phone": "={{ $('Decide').first().json.phone }}",
            "silencedUntil": "={{ Date.now() + $('Config').first().json.handoffSilenceHours * 3600e3 }}",
            "silenceReason": "handoff"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "silencedUntil",
              "displayName": "silencedUntil",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "silenceReason",
              "displayName": "silenceReason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "wacs-27",
      "name": "Silence: hand-off",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        2560,
        160
      ],
      "notes": "ES: Calla al bot en ESTA conversación por handoffSilenceHours. Si durante ese tiempo contestás desde el celular, cada respuesta tuya vuelve a estirar el silencio (message.echo).\n\nEN: Silences the bot in THIS conversation for handoffSilenceHours. If you reply from your phone meanwhile, each of your replies extends the silence again (message.echo)."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "to",
              "type": "string",
              "value": "={{ $('Decide').first().json.phone }}"
            },
            {
              "id": "r2",
              "name": "connectionId",
              "type": "string",
              "value": "={{ $('Decide').first().json.connectionId }}"
            },
            {
              "id": "r3",
              "name": "text",
              "type": "string",
              "value": "={{ $('Config').first().json.handoffText }}"
            },
            {
              "id": "r4",
              "name": "idempotencyKey",
              "type": "string",
              "value": "={{ 'handoff-' + $('Decide').first().json.wamid }}"
            }
          ]
        },
        "options": {}
      },
      "id": "wacs-28",
      "name": "Reply: hand-off",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2780,
        160
      ],
      "notes": "ES: Le dice al cliente que lo atiende una persona.\n\nEN: Tells the customer a person will take over."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "a1",
              "name": "subject",
              "type": "string",
              "value": "={{ 'WhatsApp: ' + ($('Decide').first().json.name || $('Decide').first().json.phone) + ' needs a person / necesita una persona' }}"
            },
            {
              "id": "a2",
              "name": "detail",
              "type": "string",
              "value": "={{ '+' + $('Decide').first().json.phone + ' (' + ($('Decide').first().json.name || '?') + ') wrote / escribió: ' + ($('Decide').first().json.text || ('[' + $('Decide').first().json.kind + ']')) + ' — the bot is quiet in this chat for / el bot se calla ' + $('Config').first().json.handoffSilenceHours + ' h' }}"
            },
            {
              "id": "a3",
              "name": "alertKey",
              "type": "string",
              "value": "={{ 'alert-handoff-' + $('Decide').first().json.wamid }}"
            }
          ]
        },
        "options": {}
      },
      "id": "wacs-29",
      "name": "Alert: hand-off",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        2780,
        320
      ],
      "notes": "ES: Arma el aviso al dueño: quién es, qué escribió, y que el bot se calla.\n\nEN: Builds the owner alert: who it is, what they wrote, and that the bot goes quiet."
    },
    {
      "parameters": {
        "promptType": "define",
        "text": "={{ $('Decide').first().json.aiInput }}",
        "options": {
          "systemMessage": "={{ $('Config').first().json.businessInstructions }}\n\n---\nReglas fijas / Fixed rules:\n- Hoy es {{ $('Decide').first().json.localTime }} ({{ $('Config').first().json.timezone }}).\n- Contestá en el idioma del cliente, en 1 a 3 frases, como un mensaje de WhatsApp.\n- Usá SÓLO la información de arriba. No inventes precios, stock, turnos ni promesas.\n- Si no podés responder con esa información, o el cliente pide hablar con una persona, respondé EXACTAMENTE: [[PERSONA]]\n- Nunca pidas datos de tarjetas ni contraseñas.",
          "maxIterations": 3
        }
      },
      "id": "wacs-30",
      "name": "AI agent",
      "type": "@n8n/n8n-nodes-langchain.agent",
      "typeVersion": 2.2,
      "position": [
        2560,
        520
      ],
      "notes": "ES: Contesta con IA. Lee las instrucciones del negocio de Config (un solo lugar) más unas reglas fijas: responder corto, en el idioma del cliente, sólo con lo que sabe, y decir [[PERSONA]] cuando no sabe. Tiene memoria por contacto (nodo de abajo). El modelo es un nodo aparte: se cambia sin tocar nada más.\n\nEN: Replies with AI. It reads the business instructions from Config (one place) plus fixed rules: short, in the customer's language, only with what it knows, and say [[PERSONA]] when it doesn't. It has per-contact memory (node below). The model is a separate node: swap it without touching anything else."
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "list",
          "value": "gpt-5-mini"
        },
        "options": {
          "temperature": 0.3
        }
      },
      "id": "wacs-31",
      "name": "OpenAI model",
      "type": "@n8n/n8n-nodes-langchain.lmChatOpenAi",
      "typeVersion": 1.2,
      "position": [
        2520,
        760
      ],
      "notes": "ES: El modelo (credencial de OpenAI). Para Claude: desconectá éste y conectá «Claude model (alternative)». Cualquier otro modelo de chat de n8n funciona igual.\n\nEN: The model (OpenAI credential). For Claude: disconnect this one and connect «Claude model (alternative)». Any other n8n chat model works the same.",
      "credentials": {
        "openAiApi": {
          "id": "",
          "name": "OpenAI"
        }
      }
    },
    {
      "parameters": {
        "model": {
          "__rl": true,
          "mode": "id",
          "value": "claude-haiku-4-5-20251001"
        },
        "options": {
          "temperature": 0.3
        }
      },
      "id": "wacs-32",
      "name": "Claude model (alternative)",
      "type": "@n8n/n8n-nodes-langchain.lmChatAnthropic",
      "typeVersion": 1.3,
      "position": [
        2520,
        920
      ],
      "notes": "ES: Alternativa: Claude. Viene DESACTIVADO y sin credencial a propósito — medido en n8n 2.40.7: un nodo con una credencial sin configurar hace fallar TODO el workflow, aunque esté desconectado. Para usarlo: activalo (clic derecho → Activate), elegí tu credencial de Anthropic, y conectalo al agente en lugar de «OpenAI model».\n\nEN: Alternative: Claude. It ships DEACTIVATED and without a credential on purpose — measured on n8n 2.40.7: a node with an unset credential makes the WHOLE workflow fail, even when unconnected. To use it: activate it (right-click → Activate), pick your Anthropic credential, and connect it to the agent instead of «OpenAI model».",
      "disabled": true
    },
    {
      "parameters": {
        "sessionIdType": "customKey",
        "sessionKey": "={{ $('Decide').first().json.phone }}",
        "contextWindowLength": 10
      },
      "id": "wacs-33",
      "name": "Memory per contact",
      "type": "@n8n/n8n-nodes-langchain.memoryBufferWindow",
      "typeVersion": 1.3,
      "position": [
        2700,
        760
      ],
      "notes": "ES: Memoria por contacto (la clave es su número): recuerda los últimos 10 intercambios. Vive en la memoria de n8n: se borra si n8n se reinicia y no sirve en modo cola (queue mode). Para algo duradero, cambiala por «Postgres Chat Memory» o «Redis Chat Memory» con la misma clave.\n\nEN: Per-contact memory (the key is their number): remembers the last 10 exchanges. It lives in n8n's memory: it's lost when n8n restarts and doesn't work in queue mode. For something durable, swap it for «Postgres Chat Memory» or «Redis Chat Memory» with the same key."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "h",
              "leftValue": "={{ $json.output }}",
              "rightValue": "[[PERSONA]]",
              "operator": {
                "type": "string",
                "operation": "contains"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "wacs-34",
      "name": "AI asked for a person?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2780,
        520
      ],
      "notes": "ES: Si la IA contestó [[PERSONA]] (no sabe, o el cliente pidió una persona con otras palabras) → el mismo pase a una persona. Si no → se manda su respuesta.\n\nEN: If the AI answered [[PERSONA]] (it doesn't know, or the customer asked for a person in other words) → the same hand-off. Otherwise → its answer is sent."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "r1",
              "name": "to",
              "type": "string",
              "value": "={{ $('Decide').first().json.phone }}"
            },
            {
              "id": "r2",
              "name": "connectionId",
              "type": "string",
              "value": "={{ $('Decide').first().json.connectionId }}"
            },
            {
              "id": "r3",
              "name": "text",
              "type": "string",
              "value": "={{ $json.output }}"
            },
            {
              "id": "r4",
              "name": "idempotencyKey",
              "type": "string",
              "value": "={{ 'ai-' + $('Decide').first().json.wamid }}"
            }
          ]
        },
        "options": {}
      },
      "id": "wacs-35",
      "name": "Reply: AI answer",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        3000,
        600
      ],
      "notes": "ES: La respuesta de la IA, tal cual. Es respuesta a un mensaje del cliente: dentro de la ventana de 24 h.\n\nEN: The AI's answer, as-is. It answers a customer message: inside the 24-hour window."
    },
    {
      "parameters": {},
      "id": "wacs-36",
      "name": "Stay quiet (on purpose)",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        2560,
        1080
      ],
      "notes": "ES: Acá termina lo que el bot decidió NO contestar: dado de baja, callado porque contestaste vos o porque pasó a una persona, fuera de horario ya avisado, o una reacción. El motivo queda en la salida de «Decide» (reason).\n\nEN: What the bot decided NOT to answer ends here: opted out, silenced because you replied or it was handed off, out of hours already told, or a reaction. The reason is in «Decide»'s output (reason)."
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Config').first().json.apiBase }}/v1/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.idempotencyKey }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ connectionId: $json.connectionId, to: $json.to, type: 'text', text: { body: $json.text } }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "timeout": 15000
        }
      },
      "id": "wacs-37",
      "name": "Send via Connect",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3240,
        0
      ],
      "notes": "ES: El ÚNICO nodo que manda mensajes: POST /v1/messages de Connect, con la credencial «Connect API key» (Authorization: Bearer wc_live_…). Idempotency-Key atado al mensaje del cliente: si algo se repite, Connect devuelve el mismo envío (200) en vez de mandar otro. Connect contesta 202 = aceptado, todavía no entregado. Sin reintentos a propósito (un error se registra y se avisa; nunca un bucle).\n\nEN: The ONLY node that sends messages: Connect's POST /v1/messages, with the «Connect API key» credential (Authorization: Bearer wc_live_…). Idempotency-Key tied to the customer's message: if anything repeats, Connect returns the same send (200) instead of sending another. Connect answers 202 = accepted, not yet delivered. No retries on purpose (an error is logged and alerted; never a loop).",
      "credentials": {
        "httpHeaderAuth": {
          "id": "",
          "name": "Connect API key"
        }
      }
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "s1",
              "leftValue": "={{ $json.statusCode }}",
              "rightValue": 200,
              "operator": {
                "type": "number",
                "operation": "gte"
              }
            },
            {
              "id": "s2",
              "leftValue": "={{ $json.statusCode }}",
              "rightValue": 300,
              "operator": {
                "type": "number",
                "operation": "lt"
              }
            },
            {
              "id": "s3",
              "leftValue": "={{ $json.body && $json.body.status }}",
              "rightValue": "held",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "wacs-38",
      "name": "Connect accepted?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3460,
        0
      ],
      "notes": "ES: 2xx y no «held» = aceptado. Cualquier otra cosa (401 clave mal, 403 cuenta suspendida o prueba vencida, 409 número no conectado, 429 cupo o límite, 400 cuerpo mal, o 202 «held») va al registro de errores y te avisa.\n\nEN: 2xx and not «held» = accepted. Anything else (401 bad key, 403 suspended account or expired trial, 409 number not connected, 429 quota or rate limit, 400 bad body, or 202 «held») goes to the error log and alerts you."
    },
    {
      "parameters": {},
      "id": "wacs-39",
      "name": "Sent",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        3680,
        -100
      ],
      "notes": "ES: Aceptado por Connect. Que llegue al teléfono es otro paso: si Meta lo rechaza después (por ejemplo la ventana de 24 h, 131047), llega un message.status «failed» y lo maneja la rama de estados.\n\nEN: Accepted by Connect. Reaching the phone is another step: if Meta rejects it later (e.g. the 24-hour window, 131047), a «failed» message.status arrives and the status branch handles it."
    },
    {
      "parameters": {
        "jsCode": "// Connect errors: { error: { code, message } } — switch on `code`, never on the text.\nconst r = $input.first().json;\nconst b = r.body || {};\nconst code = (b.error && b.error.code) || (b.status === 'held' ? 'HELD:' + (b.reason || '') : 'HTTP_' + r.statusCode);\nreturn [{ json: {\n  phone: String($('Decide').first().json.phone || ''),\n  code,\n  detail: ((b.error && b.error.message) || JSON.stringify(b)).slice(0, 500),\n  at: Date.now(),\n  subject: 'WhatsApp: Connect rejected a reply / Connect rechazó una respuesta (' + code + ')',\n  alertKey: 'alert-send-' + $('Decide').first().json.wamid,\n} }];"
      },
      "id": "wacs-40",
      "name": "Describe send error",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3680,
        100
      ],
      "notes": "ES: Arma el error con el code de Connect (el contrato es el code, no el texto: API_KEY_INVALID, ACCOUNT_SUSPENDED, CONNECTION_NOT_READY, QUOTA_EXCEEDED, RATE_LIMITED…).\n\nEN: Builds the error with Connect's code (the contract is the code, not the text: API_KEY_INVALID, ACCOUNT_SUSPENDED, CONNECTION_NOT_READY, QUOTA_EXCEEDED, RATE_LIMITED…)."
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "insert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "connect_bot_errors"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "at": "={{ $json.at }}",
            "phone": "={{ $json.phone }}",
            "code": "={{ $json.code }}",
            "detail": "={{ $json.detail }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "at",
              "displayName": "at",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "code",
              "displayName": "code",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "detail",
              "displayName": "detail",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "wacs-41",
      "name": "Log error",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        3900,
        100
      ],
      "notes": "ES: Deja el error en la tabla connect_bot_errors (Data tables → abrila para ver el historial). No se reintenta.\n\nEN: Writes the error to the connect_bot_errors table (Data tables → open it to see the history). No retry."
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "e1",
              "name": "subject",
              "type": "string",
              "value": "={{ $('Log error').first().json.code ? 'WhatsApp bot: ' + $('Log error').first().json.code : 'WhatsApp bot error' }}"
            },
            {
              "id": "e2",
              "name": "detail",
              "type": "string",
              "value": "={{ '+' + $('Log error').first().json.phone + ' — ' + $('Log error').first().json.code + ': ' + $('Log error').first().json.detail }}"
            },
            {
              "id": "e3",
              "name": "alertKey",
              "type": "string",
              "value": "={{ 'alert-err-' + $('Log error').first().json.code + '-' + $('Log error').first().json.at }}"
            }
          ]
        },
        "options": {}
      },
      "id": "wacs-42",
      "name": "Alert: error",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        4120,
        100
      ],
      "notes": "ES: Arma el aviso del error para el dueño.\n\nEN: Builds the error alert for the owner."
    },
    {
      "parameters": {
        "jsCode": "// message.echo = Meta's echo of a message sent FROM THE PHONE (Coexistence).\n// message.to is the customer; message.from is your business number.\nconst cfg = $('Config').first().json;\nconst m = ($input.first().json.body.data || {}).message || {};\nconst text = m.type === 'text' ? String((m.text && m.text.body) || '') : '[' + (m.type || '?') + ']';\nreturn [{ json: {\n  phone: String(m.to || '').replace(/\\D/g, ''),\n  silencedUntil: Date.now() + Number(cfg.ownerReplySilenceMinutes) * 60e3,\n  ownerNote: text.slice(0, 500),\n} }];"
      },
      "id": "wacs-43",
      "name": "Owner replied from phone",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1220,
        800
      ],
      "notes": "ES: 🔑 Lo que hace única a la convivencia: cuando VOS contestás desde la app de WhatsApp Business, Connect manda un message.echo con lo que escribiste (message.to = el cliente). El bot se calla en ESA conversación por ownerReplySilenceMinutes, y guarda tu respuesta para que la IA sepa qué le dijiste cuando vuelva. ⚠ Los mensajes automáticos de la app (bienvenida, ausencia) también salen del teléfono: si los tenés activos, cuentan como respuesta tuya.\n\nEN: 🔑 What makes Coexistence unique: when YOU reply from the WhatsApp Business app, Connect sends a message.echo with what you wrote (message.to = the customer). The bot goes quiet in THAT conversation for ownerReplySilenceMinutes, and keeps your reply so the AI knows what you said when it comes back. ⚠ The app's automatic messages (greeting, away) also leave from the phone: if you have them on, they count as your reply."
    },
    {
      "parameters": {
        "resource": "row",
        "operation": "upsert",
        "dataTableId": {
          "__rl": true,
          "mode": "name",
          "value": "connect_bot_contacts"
        },
        "matchType": "anyCondition",
        "filters": {
          "conditions": [
            {
              "keyName": "phone",
              "condition": "eq",
              "keyValue": "={{ $json.phone }}"
            }
          ]
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "phone": "={{ $json.phone }}",
            "silencedUntil": "={{ $json.silencedUntil }}",
            "silenceReason": "owner replied",
            "ownerNote": "={{ $json.ownerNote }}"
          },
          "matchingColumns": [],
          "schema": [
            {
              "id": "phone",
              "displayName": "phone",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "silencedUntil",
              "displayName": "silencedUntil",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "silenceReason",
              "displayName": "silenceReason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            },
            {
              "id": "ownerNote",
              "displayName": "ownerNote",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "readOnly": false,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "wacs-44",
      "name": "Silence: owner replied",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        1440,
        800
      ],
      "notes": "ES: Guarda el silencio (hasta cuándo) y tu última respuesta. Cada respuesta tuya lo estira de nuevo.\n\nEN: Saves the silence (until when) and your last reply. Each reply of yours extends it again."
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "loose",
            "version": 2
          },
          "conditions": [
            {
              "id": "f1",
              "leftValue": "={{ $json.body.data.status }}",
              "rightValue": "failed",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            },
            {
              "id": "f2",
              "leftValue": "={{ $json.body.data.origin }}",
              "rightValue": "api",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            },
            {
              "id": "f3",
              "leftValue": "={{ String($json.body.data.recipient || '').replace(/\\D/g, '') }}",
              "rightValue": "={{ String($('Config').first().json.ownerWhatsapp || '').replace(/\\D/g, '') }}",
              "operator": {
                "type": "string",
                "operation": "notEquals"
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "wacs-45",
      "name": "Our send failed?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1220,
        1080
      ],
      "notes": "ES: Sólo importan los envíos NUESTROS (origin api) que fallaron: Meta los rechazó después del 202. ⚠ Un fallo de un aviso a TU WhatsApp no genera otro aviso: así nunca hay un bucle.\n\nEN: Only OUR sends (origin api) that failed matter: Meta rejected them after the 202. ⚠ A failed alert to YOUR WhatsApp doesn't trigger another alert: so there's never a loop."
    },
    {
      "parameters": {
        "jsCode": "// failureCode = Connect's stable code (same as lastErrorCode in GET /v1/messages/{id}).\n// WINDOW_24H_EXPIRED (131047) · TEMPLATE_PARAM_INVALID (132018) · META_PAYMENT_METHOD (131042) …\nconst d = $input.first().json.body.data || {};\nconst e = (d.errors && d.errors[0]) || {};\nconst code = d.failureCode || (e.code ? 'META_' + e.code : 'SEND_FAILED');\nconst hint = {\n  WINDOW_24H_EXPIRED: 'The 24-hour window was closed: only an approved template can reach this contact / La ventana de 24 h estaba cerrada: sólo una plantilla aprobada.',\n  META_PAYMENT_METHOD: 'Payment method problem at Meta (131042) — fix it in Meta Business Manager / Problema con tu medio de pago en Meta.',\n  TEMPLATE_PARAM_INVALID: 'Template parameter rejected (132018): no line breaks, tabs or 5+ spaces in a parameter / Parámetro de plantilla rechazado.',\n}[code] || '';\nreturn [{ json: {\n  phone: String(d.recipient || '').replace(/\\D/g, ''),\n  code,\n  detail: ((e.title || e.message || '') + (hint ? ' — ' + hint : '')).slice(0, 500),\n  at: Date.now(),\n  alertKey: 'alert-status-' + (d.messageId || $input.first().json.body.id),\n} }];"
      },
      "id": "wacs-46",
      "name": "Describe failure",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1440,
        1000
      ],
      "notes": "ES: Traduce el fallo: failureCode de Connect (el mismo lastErrorCode de GET /v1/messages/{id}) y una pista para los tres que más pasan — WINDOW_24H_EXPIRED (131047), TEMPLATE_PARAM_INVALID (132018), META_PAYMENT_METHOD (131042). No se reintenta: el mismo mensaje fallaría igual.\n\nEN: Translates the failure: Connect's failureCode (same lastErrorCode as GET /v1/messages/{id}) and a hint for the three most common — WINDOW_24H_EXPIRED (131047), TEMPLATE_PARAM_INVALID (132018), META_PAYMENT_METHOD (131042). Not retried: the same message would fail the same way."
    },
    {
      "parameters": {},
      "id": "wacs-47",
      "name": "Delivery receipt: nothing to do",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1440,
        1160
      ],
      "notes": "ES: Acuses normales (sent, delivered, read) y los de mensajes mandados desde el teléfono (origin device): nada que hacer.\n\nEN: Normal receipts (sent, delivered, read) and those of messages sent from the phone (origin device): nothing to do."
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $('Config').first().json.ownerAlert }}",
                    "rightValue": "email",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "email"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "leftValue": "={{ $('Config').first().json.ownerAlert }}",
                    "rightValue": "whatsapp_template",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "whatsapp_template"
            }
          ]
        },
        "options": {
          "fallbackOutput": "extra",
          "renameFallbackOutput": "none"
        }
      },
      "id": "wacs-48",
      "name": "Owner alert channel",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        4400,
        300
      ],
      "notes": "ES: Cómo te avisa (Config → ownerAlert): email (nodo Send Email, credencial SMTP) o whatsapp_template (una PLANTILLA aprobada a tu número personal). ⚠ Nunca texto libre a tu WhatsApp: fuera de la ventana de 24 h Meta lo rechaza (131047), y es la regla de Meta. none = sin aviso (queda en la tabla de errores y en las ejecuciones).\n\nEN: How you get alerted (Config → ownerAlert): email (Send Email node, SMTP credential) or whatsapp_template (an APPROVED template to your personal number). ⚠ Never free text to your WhatsApp: outside the 24-hour window Meta rejects it (131047), and it's Meta's rule. none = no alert (it stays in the errors table and the executions)."
    },
    {
      "parameters": {
        "fromEmail": "={{ $('Config').first().json.fromEmail }}",
        "toEmail": "={{ $('Config').first().json.ownerEmail }}",
        "subject": "={{ $json.subject }}",
        "emailFormat": "text",
        "text": "={{ $json.detail }}",
        "options": {
          "appendAttribution": false
        }
      },
      "id": "wacs-49",
      "name": "Email the owner",
      "type": "n8n-nodes-base.emailSend",
      "typeVersion": 2.1,
      "position": [
        4640,
        200
      ],
      "notes": "ES: Te manda el aviso por correo (credencial SMTP de n8n: Gmail, tu hosting, lo que uses). Si falla, la ejecución sigue: un aviso que no sale no frena al bot.\n\nEN: Emails you the alert (n8n SMTP credential: Gmail, your host, anything). If it fails, the execution goes on: a failed alert doesn't stop the bot.",
      "credentials": {
        "smtp": {
          "id": "",
          "name": "SMTP"
        }
      },
      "onError": "continueRegularOutput"
    },
    {
      "parameters": {
        "method": "POST",
        "url": "={{ $('Config').first().json.apiBase }}/v1/messages",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendHeaders": true,
        "headerParameters": {
          "parameters": [
            {
              "name": "Idempotency-Key",
              "value": "={{ $json.alertKey }}"
            }
          ]
        },
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ connectionId: $('Config').first().json.body.connection.id, to: $('Config').first().json.ownerWhatsapp, type: 'template', template: { name: $('Config').first().json.ownerTemplateName, language: { code: $('Config').first().json.ownerTemplateLanguage }, components: [ { type: 'body', parameters: [ { type: 'text', text: String($json.detail).replace(/\\s+/g, ' ').trim().slice(0, 900) } ] } ] } }) }}",
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true
            }
          },
          "timeout": 15000
        }
      },
      "id": "wacs-50",
      "name": "WhatsApp template to owner",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        4640,
        400
      ],
      "notes": "ES: Te avisa por WhatsApp con una PLANTILLA aprobada (ownerTemplateName, con UNA variable {{1}} en el cuerpo), a tu número personal (ownerWhatsapp). El texto se limpia de saltos de línea, tabs y 5+ espacios: Meta rechaza esos parámetros (132018). Sin reintentos.\n\nEN: Alerts you on WhatsApp with an APPROVED template (ownerTemplateName, with ONE {{1}} variable in the body), to your personal number (ownerWhatsapp). The text is stripped of line breaks, tabs and 5+ spaces: Meta rejects such parameters (132018). No retries.",
      "credentials": {
        "httpHeaderAuth": {
          "id": "",
          "name": "Connect API key"
        }
      }
    },
    {
      "parameters": {},
      "id": "wacs-51",
      "name": "No alert",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        4640,
        580
      ],
      "notes": "ES: ownerAlert = none: sin aviso. Lo tenés en la tabla connect_bot_errors y en las ejecuciones.\n\nEN: ownerAlert = none: no alert. It's in the connect_bot_errors table and in the executions."
    },
    {
      "parameters": {},
      "id": "wacs-52",
      "name": "Run once: create tables",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        -540,
        800
      ],
      "notes": "ES: Apretá ▶ acá UNA vez, antes de activar. Crea las 3 tablas (Data tables) que usa el bot. Si ya existen, no hace nada.\n\nEN: Press ▶ here ONCE, before activating. Creates the 3 Data tables the bot uses. If they exist, it does nothing."
    },
    {
      "parameters": {
        "resource": "table",
        "operation": "create",
        "tableName": "connect_bot_seen",
        "columns": {
          "column": [
            {
              "name": "key",
              "type": "string"
            },
            {
              "name": "at",
              "type": "number"
            }
          ]
        },
        "options": {
          "createIfNotExists": true
        }
      },
      "id": "wacs-53",
      "name": "Table: seen",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        -320,
        800
      ],
      "notes": "ES: connect_bot_seen: los wamid ya procesados (para no contestar dos veces). Podés vaciarla de vez en cuando: basta con los últimos días.\n\nEN: connect_bot_seen: the wamids already processed (so nothing is answered twice). You can clear it now and then: the last few days are enough.",
      "executeOnce": true
    },
    {
      "parameters": {
        "resource": "table",
        "operation": "create",
        "tableName": "connect_bot_contacts",
        "columns": {
          "column": [
            {
              "name": "phone",
              "type": "string"
            },
            {
              "name": "optedOut",
              "type": "boolean"
            },
            {
              "name": "silencedUntil",
              "type": "number"
            },
            {
              "name": "silenceReason",
              "type": "string"
            },
            {
              "name": "closedNoticeAt",
              "type": "number"
            },
            {
              "name": "ownerNote",
              "type": "string"
            }
          ]
        },
        "options": {
          "createIfNotExists": true
        }
      },
      "id": "wacs-54",
      "name": "Table: contacts",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        -100,
        800
      ],
      "notes": "ES: connect_bot_contacts: una fila por cliente — baja, silencio (hasta cuándo y por qué), último aviso de cerrado, tu última respuesta desde el celular. Para devolverle la palabra al bot antes de tiempo, borrá su silencedUntil.\n\nEN: connect_bot_contacts: one row per customer — opt-out, silence (until when and why), last closed notice, your last reply from the phone. To give the bot back the word early, clear its silencedUntil.",
      "executeOnce": true
    },
    {
      "parameters": {
        "resource": "table",
        "operation": "create",
        "tableName": "connect_bot_errors",
        "columns": {
          "column": [
            {
              "name": "at",
              "type": "number"
            },
            {
              "name": "phone",
              "type": "string"
            },
            {
              "name": "code",
              "type": "string"
            },
            {
              "name": "detail",
              "type": "string"
            }
          ]
        },
        "options": {
          "createIfNotExists": true
        }
      },
      "id": "wacs-55",
      "name": "Table: errors",
      "type": "n8n-nodes-base.dataTable",
      "typeVersion": 1,
      "position": [
        120,
        800
      ],
      "notes": "ES: connect_bot_errors: cada envío que Connect o Meta rechazó, con su code.\n\nEN: connect_bot_errors: every send Connect or Meta rejected, with its code.",
      "executeOnce": true
    }
  ],
  "connections": {
    "Connect webhook": {
      "main": [
        [
          {
            "node": "Read raw body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read raw body": {
      "main": [
        [
          {
            "node": "Config",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Config": {
      "main": [
        [
          {
            "node": "HMAC of the body",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "HMAC of the body": {
      "main": [
        [
          {
            "node": "Check signature",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check signature": {
      "main": [
        [
          {
            "node": "Signature OK?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Signature OK?": {
      "main": [
        [
          {
            "node": "Accept (200)",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reject (401)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Accept (200)": {
      "main": [
        [
          {
            "node": "Event type",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Event type": {
      "main": [
        [
          {
            "node": "Normalize message",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Owner replied from phone",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Our send failed?",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Other events: nothing to do",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Normalize message": {
      "main": [
        [
          {
            "node": "Seen before?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Seen before?": {
      "main": [
        [
          {
            "node": "Mark as seen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Mark as seen": {
      "main": [
        [
          {
            "node": "Contact state",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Contact state": {
      "main": [
        [
          {
            "node": "Decide",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Decide": {
      "main": [
        [
          {
            "node": "Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route": {
      "main": [
        [
          {
            "node": "Save opt-out",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Save opt-in",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Save closed notice",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply: media",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Silence: hand-off",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "AI agent",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Stay quiet (on purpose)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save opt-out": {
      "main": [
        [
          {
            "node": "Reply: opt-out",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save opt-in": {
      "main": [
        [
          {
            "node": "Reply: opt-in",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Save closed notice": {
      "main": [
        [
          {
            "node": "Reply: closed",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Silence: hand-off": {
      "main": [
        [
          {
            "node": "Reply: hand-off",
            "type": "main",
            "index": 0
          },
          {
            "node": "Alert: hand-off",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "OpenAI model": {
      "ai_languageModel": [
        [
          {
            "node": "AI agent",
            "type": "ai_languageModel",
            "index": 0
          }
        ]
      ]
    },
    "Memory per contact": {
      "ai_memory": [
        [
          {
            "node": "AI agent",
            "type": "ai_memory",
            "index": 0
          }
        ]
      ]
    },
    "AI agent": {
      "main": [
        [
          {
            "node": "AI asked for a person?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "AI asked for a person?": {
      "main": [
        [
          {
            "node": "Silence: hand-off",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Reply: AI answer",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply: opt-out": {
      "main": [
        [
          {
            "node": "Send via Connect",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply: opt-in": {
      "main": [
        [
          {
            "node": "Send via Connect",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply: closed": {
      "main": [
        [
          {
            "node": "Send via Connect",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply: media": {
      "main": [
        [
          {
            "node": "Send via Connect",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply: hand-off": {
      "main": [
        [
          {
            "node": "Send via Connect",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Reply: AI answer": {
      "main": [
        [
          {
            "node": "Send via Connect",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Send via Connect": {
      "main": [
        [
          {
            "node": "Connect accepted?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Connect accepted?": {
      "main": [
        [
          {
            "node": "Sent",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Describe send error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Describe send error": {
      "main": [
        [
          {
            "node": "Log error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Log error": {
      "main": [
        [
          {
            "node": "Alert: error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Owner replied from phone": {
      "main": [
        [
          {
            "node": "Silence: owner replied",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Our send failed?": {
      "main": [
        [
          {
            "node": "Describe failure",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Delivery receipt: nothing to do",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Describe failure": {
      "main": [
        [
          {
            "node": "Log error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Alert: hand-off": {
      "main": [
        [
          {
            "node": "Owner alert channel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Alert: error": {
      "main": [
        [
          {
            "node": "Owner alert channel",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Owner alert channel": {
      "main": [
        [
          {
            "node": "Email the owner",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "WhatsApp template to owner",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "No alert",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run once: create tables": {
      "main": [
        [
          {
            "node": "Table: seen",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Table: seen": {
      "main": [
        [
          {
            "node": "Table: contacts",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Table: contacts": {
      "main": [
        [
          {
            "node": "Table: errors",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "active": false,
  "settings": {
    "executionOrder": "v1",
    "timezone": "America/Argentina/Cordoba"
  },
  "pinData": {},
  "meta": {
    "templateCredsSetupCompleted": false
  },
  "tags": []
}
