Skip to content

In n8n, copy this file and paste it onto the canvas, or use Import from File.

{
  "name": "20 · WhatsApp AI Assistant (starter)",
  "nodes": [
    {
      "parameters": {
        "updates": [
          "messages"
        ],
        "options": {}
      },
      "id": "m20-whatsapp-trigger",
      "name": "WhatsApp Trigger",
      "type": "n8n-nodes-base.whatsAppTrigger",
      "typeVersion": 1,
      "position": [
        0,
        0
      ],
      "webhookId": "course-20-wa-trigger"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "wa/inbound",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m20-inbound-api",
      "name": "Inbound API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        180
      ],
      "webhookId": "course-20-inbound"
    },
    {
      "parameters": {
        "httpMethod": "GET",
        "path": "wa/inbound",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m20-verify-api",
      "name": "Verify API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        400
      ],
      "webhookId": "course-20-verify"
    },
    {
      "parameters": {
        "httpMethod": "GET",
        "path": "wa/conversation",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m20-conversation-api",
      "name": "Conversation API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        580
      ],
      "webhookId": "course-20-conversation"
    },
    {
      "parameters": {
        "httpMethod": "POST",
        "path": "wa/handoff/release",
        "responseMode": "responseNode",
        "options": {}
      },
      "id": "m20-release-handoff-api",
      "name": "Release Handoff API",
      "type": "n8n-nodes-base.webhook",
      "typeVersion": 2,
      "position": [
        0,
        760
      ],
      "webhookId": "course-20-release"
    },
    {
      "parameters": {
        "assignments": {
          "assignments": [
            {
              "id": "s1",
              "name": "business_name",
              "value": "Cool Breeze Heating & Air",
              "type": "string"
            },
            {
              "id": "s2",
              "name": "phone_number_id",
              "value": "123456789012345",
              "type": "string"
            },
            {
              "id": "s3",
              "name": "verify_token",
              "value": "course-20-verify-me",
              "type": "string"
            },
            {
              "id": "s4",
              "name": "use_ai",
              "value": true,
              "type": "boolean"
            },
            {
              "id": "s5",
              "name": "timezone",
              "value": "America/Chicago",
              "type": "string"
            },
            {
              "id": "s6",
              "name": "hours",
              "value": "={{ { mon: { open: 8, close: 18 }, tue: { open: 8, close: 18 }, wed: { open: 8, close: 18 }, thu: { open: 8, close: 18 }, fri: { open: 8, close: 18 }, sat: { open: 9, close: 13 } } }}",
              "type": "object"
            },
            {
              "id": "s7",
              "name": "hours_text",
              "value": "Mon–Fri 8 AM–6 PM, Sat 9 AM–1 PM (Central)",
              "type": "string"
            },
            {
              "id": "s8",
              "name": "service_area",
              "value": "Austin, Round Rock, Pflugerville and Cedar Park",
              "type": "string"
            },
            {
              "id": "s9",
              "name": "service_zips",
              "value": "={{ ['78701','78702','78703','78704','78705','78721','78722','78723','78727','78728','78729','78731','78745','78748','78750','78751','78752','78753','78757','78758','78759','78660','78664','78665','78681','78613'] }}",
              "type": "array"
            },
            {
              "id": "s10",
              "name": "services",
              "value": "={{ [ { name: 'AC repair', keywords: ['ac ', 'a/c', 'air condition', 'cooling', 'not cooling', 'warm air'] }, { name: 'heating repair', keywords: ['heat', 'furnace', 'heater', 'no heat'] }, { name: 'maintenance tune-up', keywords: ['tune', 'maintenance', 'check-up', 'checkup', 'service plan'] }, { name: 'new system install', keywords: ['new system', 'replace', 'install', 'new unit', 'new ac'] } ] }}",
              "type": "array"
            },
            {
              "id": "s11",
              "name": "faq",
              "value": "={{ [ { keywords: ['hour', 'open', 'close', 'weekend', 'saturday', 'sunday'], answer: 'We are open Mon–Fri 8 AM–6 PM and Sat 9 AM–1 PM (Central). Messages after hours get a reply the next time we open.' }, { keywords: ['price', 'cost', 'how much', 'fee', 'charge', 'diagnostic'], answer: 'Our diagnostic visit is $89, credited toward the repair if you go ahead. Tune-ups are $129. Repairs are quoted on site before any work starts.' }, { keywords: ['area', 'serve', 'location', 'come to', 'zip'], answer: 'We serve Austin, Round Rock, Pflugerville and Cedar Park.' }, { keywords: ['financ', 'payment plan', 'monthly'], answer: 'Financing is available on new systems, with plans from 12 to 60 months (subject to approval).' }, { keywords: ['warranty', 'guarantee'], answer: 'Repairs come with a 1-year parts and labor warranty; new systems with a 10-year parts warranty.' } ] }}",
              "type": "array"
            },
            {
              "id": "s12",
              "name": "emergency_keywords",
              "value": "={{ ['smell gas', 'gas smell', 'gas leak', 'carbon monoxide', 'co alarm', 'smoke', 'sparks', 'fire'] }}",
              "type": "array"
            },
            {
              "id": "s13",
              "name": "confirm_within",
              "value": "1 business hour",
              "type": "string"
            },
            {
              "id": "s14",
              "name": "handoff_hours",
              "value": 12,
              "type": "number"
            },
            {
              "id": "s15",
              "name": "memory_turns",
              "value": 10,
              "type": "number"
            }
          ]
        },
        "includeOtherFields": true,
        "options": {}
      },
      "id": "m20-settings-edit-me",
      "name": "Settings (edit me)",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        240,
        380
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r1",
                    "leftValue": "={{ $('WhatsApp Trigger').isExecuted || $('Inbound API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "message"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r2",
                    "leftValue": "={{ $('Verify API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "verify"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r3",
                    "leftValue": "={{ $('Conversation API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "conversation"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 2
                },
                "conditions": [
                  {
                    "id": "r4",
                    "leftValue": "={{ $('Release Handoff API').isExecuted }}",
                    "rightValue": "",
                    "operator": {
                      "type": "boolean",
                      "operation": "true",
                      "singleValue": true
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "release"
            }
          ]
        },
        "options": {}
      },
      "id": "m20-route",
      "name": "Route",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.2,
      "position": [
        460,
        380
      ]
    },
    {
      "parameters": {
        "jsCode": "// Turn a WhatsApp Cloud API webhook into one item per incoming message.\n// Accepts the raw webhook body (Inbound API) or the WhatsApp Trigger's items (one per change.value).\nconst viaApi = $('Inbound API').isExecuted;\nconst q = viaApi ? $('Inbound API').first().json.query ?? {} : {};\nconst values = viaApi\n  ? ($('Inbound API').first().json.body?.entry ?? []).flatMap((e) => e.changes ?? []).map((c) => c.value ?? {})\n  : $('WhatsApp Trigger').all().map((i) => i.json);\n\nconst textOf = (m) =>\n  m.text?.body ??\n  m.interactive?.button_reply?.title ??\n  m.interactive?.list_reply?.title ??\n  m.button?.text ??\n  m[m.type]?.caption ??\n  '';\n\nconst out = [];\nfor (const v of values) {\n  const names = Object.fromEntries((v.contacts ?? []).map((c) => [c.wa_id, c.profile?.name ?? '']));\n  for (const m of v.messages ?? []) {\n    out.push({\n      json: {\n        wa_id: String(m.from ?? ''),\n        profile_name: names[m.from] ?? '',\n        message_id: String(m.id ?? ''),\n        type: m.type ?? 'unknown',\n        text: String(textOf(m)).trim().slice(0, 2000),\n        received_at: m.timestamp ? new Date(Number(m.timestamp) * 1000).toISOString() : new Date().toISOString(),\n        phone_number_id: v.metadata?.phone_number_id ?? '',\n        dry_run: q.dry_run === 'true',\n        now: q.now || new Date().toISOString(), // tests inject the clock with ?now=\n        via_api: viaApi,\n      },\n    });\n  }\n}\n// Status updates (sent/delivered/read) have no messages: return one marker item so the API caller gets an answer.\nreturn out.length ? out : [{ json: { ignored: true, reason: 'no messages (status update or other event)', via_api: viaApi } }];\n"
      },
      "id": "m20-parse-messages",
      "name": "Parse Messages",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        700,
        60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ !$json.ignored }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m20-any-messages",
      "name": "Any Messages?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        920,
        60
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $json.via_api }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m20-ignored-via-api",
      "name": "Ignored via API?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1140,
        240
      ]
    },
    {
      "parameters": {
        "respondWith": "json",
        "responseBody": "={{ { ignored: true, reason: $json.reason } }}",
        "options": {}
      },
      "id": "m20-respond-ignored",
      "name": "Respond Ignored",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        1360,
        240
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c1",
              "leftValue": "={{ $('Verify API').first().json.query['hub.mode'] === 'subscribe' && $('Verify API').first().json.query['hub.verify_token'] === $('Settings (edit me)').first().json.verify_token }}",
              "rightValue": "",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      },
      "id": "m20-token-matches",
      "name": "Token Matches?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        700,
        400
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "={{ $('Verify API').first().json.query['hub.challenge'] }}",
        "options": {}
      },
      "id": "m20-respond-challenge",
      "name": "Respond Challenge",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        920,
        360
      ]
    },
    {
      "parameters": {
        "respondWith": "text",
        "responseBody": "forbidden",
        "options": {
          "responseCode": 403
        }
      },
      "id": "m20-respond-forbidden",
      "name": "Respond Forbidden",
      "type": "n8n-nodes-base.respondToWebhook",
      "typeVersion": 1.1,
      "position": [
        920,
        500
      ]
    },
    {
      "parameters": {
        "content": "## Your task\nVerification and message parsing are done. Send a test webhook to `wa/inbound` and look at Parse Messages.\n1. **Conversation State** (Code + static data): load the contact's chat, drop retried message ids, then rules first: STOP/START, safety keywords, active handoff (stay silent), \"talk to a person\", media → else *answer*.\n2. **Answer with AI** (Basic LLM Chain + OpenAI + structured output: reply, intent, handoff, lead), *On Error: Continue*.\n3. **Pick Reply**: AI reply if present, else the rule reply (FAQ, greeting, booking questions one at a time).\n4. **Save Turn**: history, lead fields, handoff timer, opt-out. Dry runs save nothing.\n5. Dry run → respond. Else **WhatsApp → Send** (On Error: Continue) → report (sent only with a message id).\n6. Conversation status + release-handoff endpoints.\n\nTest: `./test/test.sh`",
        "height": 260,
        "width": 420
      },
      "id": "starter-task-note",
      "name": "Your Task",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -340
      ]
    }
  ],
  "connections": {
    "WhatsApp Trigger": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inbound API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Verify API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Conversation API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Release Handoff API": {
      "main": [
        [
          {
            "node": "Settings (edit me)",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Settings (edit me)": {
      "main": [
        [
          {
            "node": "Route",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route": {
      "main": [
        [
          {
            "node": "Parse Messages",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Token Matches?",
            "type": "main",
            "index": 0
          }
        ],
        [],
        []
      ]
    },
    "Parse Messages": {
      "main": [
        [
          {
            "node": "Any Messages?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Any Messages?": {
      "main": [
        [],
        [
          {
            "node": "Ignored via API?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Ignored via API?": {
      "main": [
        [
          {
            "node": "Respond Ignored",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Token Matches?": {
      "main": [
        [
          {
            "node": "Respond Challenge",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Respond Forbidden",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "pinData": {}
}