This workflow follows the Error Trigger → HTTP Request recipe pattern — see all workflows that pair these two integrations.
The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"createdAt": "2025-08-14T08:17:18.929Z",
"updatedAt": "2025-10-12T08:47:43.000Z",
"id": "tjk89cBgzinji59E",
"name": "Bubu WhatsApp Companion",
"active": true,
"isArchived": false,
"nodes": [
{
"parameters": {
"path": "whatsapp",
"responseMode": "responseNode",
"options": {}
},
"id": "fc6abf81-7b16-4ed8-99bf-21500037cdd4",
"name": "Webhook (GET)",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
-2336,
160
]
},
{
"parameters": {
"language": "JavaScript",
"jsCode": "const query = $json.query || {};\nconst mode = query['hub.mode'];\nconst token = query['hub.verify_token'];\nconst challenge = query['hub.challenge'];\n\nif (mode === 'subscribe' && token === 'bubu-verify-2024') {\n return [{json: {status: 200, body: challenge}}];\n}\nreturn [{json: {status: 403, body: 'Forbidden'}}];"
},
"id": "b5e7bff8-ce4e-4e03-8cc3-b8ca157e4b66",
"name": "Verify (GET)",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-2112,
160
]
},
{
"parameters": {
"respondWith": "text",
"responseBody": "={{$json.body}}",
"options": {
"responseCode": "={{$json.status}}"
}
},
"id": "9231fca4-9b9b-45a7-8f71-07324bc31f7b",
"name": "Respond (GET)",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1,
"position": [
-1888,
160
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "whatsapp",
"options": {}
},
"id": "616ad17b-b640-4477-bf3f-6700e5fb44a3",
"name": "Webhook (POST)",
"type": "n8n-nodes-base.webhook",
"typeVersion": 1,
"position": [
-2320,
496
],
"notes": "Purpose: Entry point for new messages (WA and TG).\nUse: Emits raw provider payload to \u201cParse payload\u201d.\nMeta: Immediate 200 only if provider requires an ack."
},
{
"parameters": {
"language": "JavaScript",
"jsCode": "const payload = $json.body || $json;\nconst entry = payload?.entry?.[0]?.changes?.[0]?.value;\nconst message = entry?.messages?.[0];\n\nif (!message) {\n return [{json: {skip: true, reason: entry?.statuses?.[0]?.status || 'no_message'}}];\n}\n\nreturn [{\n json: {\n phone_number: message.from,\n whatsapp_id: message.id,\n type: message.type,\n content: message.text?.body || '',\n is_voice: message.type === 'audio',\n media_id: message.audio?.id || null,\n timestamp: message.timestamp\n }\n}];"
},
"id": "398454b7-83e2-4bf1-9258-53ca15356574",
"name": "Parse payload",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-2112,
496
],
"notes": "Purpose: Normalize provider payload to a single internal shape.\nUse: WhatsApp: Output { phone_number, whatsapp_id, type, content, is_voice, media_id, timestamp }.\nMeta: Voice path enabled for both (media id passed forward for download). TG group visibility depends on BotFather Privacy Mode (DMs unaffected).\n"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "loose",
"version": 1
},
"conditions": [
{
"id": "198ea951-649c-4838-9846-9a7968691717",
"leftValue": "={{ $json.skip }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {
"looseTypeValidation": true
}
},
"id": "3ea6f7bf-ae22-448e-a7ac-268f8e383fb8",
"name": "Skip?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-1440,
512
]
},
{
"parameters": {
"method": "POST",
"url": "https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/record_inbound",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "supabaseApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"p_msg\": {\n \"provider\": \"whatsapp\",\n \"phone_number\": \"{{ $node['Parse payload'].json.phone_number }}\",\n \"whatsapp_id\": \"{{ $node['Parse payload'].json.whatsapp_id }}\",\n \"content\": \"{{ $node['Parse payload'].json.content }}\",\n \"is_voice\": {{ !!$node['Parse payload'].json.is_voice }},\n \"timestamp\": {{ Number($node['Parse payload'].json.timestamp) }}\n }\n}",
"options": {
"response": {
"response": {
"responseFormat": "text"
}
}
}
},
"id": "56f4f512-bf8f-4a88-a707-062a307a8e34",
"name": "Supabase: record_inbound",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
-944,
528
],
"credentials": {
"supabaseApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Idempotent insert of inbound; auto-create user if new.\nUse: Body { p_msg: normalized message + provider }.\nMeta: SECURITY DEFINER; idempotent on provider message id. Identity key stored in users.phone_number:\n\u2022 WA: digits (no plus).\nTimestamp accepts ISO or epoch seconds. media_url intentionally null."
},
{
"parameters": {
"method": "POST",
"url": "https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/get_context",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "supabaseApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={ \"p_phone\": \"{{ $node['Parse payload'].json.phone_number }}\" }",
"options": {}
},
"id": "cd81574c-dd01-4412-9228-f6d5afe7b224",
"name": "Supabase: get_context",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
-768,
528
],
"credentials": {
"supabaseApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Deterministic memory for prompts.\nUse: { p_phone } \u2192 { user, children[], recent_messages[], recent_interventions[], failed_by_category[], history_summary }.\nMeta: Shared across channels."
},
{
"parameters": {
"language": "JavaScript",
"jsCode": "// --- ctx from Supabase:get_context ---\nconst ctx = $json;\n\n// Normalize legacy key\nif (ctx.failed_slugs && !ctx.failed_by_category) {\n ctx.failed_by_category = ctx.failed_slugs;\n delete ctx.failed_slugs;\n}\n\n// --- parse payload (webhook) ---\nconst parse = $node['Parse payload'].json; // has phone_number, whatsapp_id, is_voice, content (may be empty for voice)\n\n// --- choose message text (DB-first, match on whatsapp_id) ---\nconst msgs = Array.isArray(ctx.recent_messages) ? ctx.recent_messages : [];\n\n// try exact match by WA ID (handles m.whatsapp_id or m.wamid)\nconst inboundExact = [...msgs].reverse().find(m =>\n (m.direction === 'in' || m.direction === 'inbound' || m.is_inbound === true) &&\n (\n (m.whatsapp_id && parse.whatsapp_id && m.whatsapp_id === parse.whatsapp_id) ||\n (m.wamid && parse.whatsapp_id && m.wamid === parse.whatsapp_id)\n )\n);\n\n// fallback: last inbound message in the window\nconst inboundLast = inboundExact || [...msgs].reverse().find(m =>\n (m.direction === 'in' || m.direction === 'inbound' || m.is_inbound === true)\n) || null;\n\n// prefer DB text (already the transcript for voice), else webhook content\nconst dbText = inboundLast?.content ?? inboundLast?.text ?? null;\nconst text = String(dbText ?? parse.content ?? '').trim();\n\n// --- children / language ---\nconst children = Array.isArray(ctx.children) ? ctx.children : [];\nconst lang = ctx.user?.preferred_language || 'en';\n\n// --- pick active child ---\nlet active = null;\nif (children.length === 1) active = children[0];\nelse if (children.length > 1) {\n const lower = text.toLowerCase();\n const hit = children.find(c => lower.includes(String(c.name || '').toLowerCase()));\n if (hit) active = hit;\n}\nif (!active && ctx.user?.last_active_child_id) {\n active = children.find(c => c.id === ctx.user.last_active_child_id) || null;\n}\nconst active_child_hint = active ? { id: active.id, name: active.name } : null;\n\n// --- build orchestrator_input ---\nconst orchestrator_input = {\n lang,\n children: children.map(c => ({\n id: c.id,\n name: String(c.name || '').trim(),\n date_of_birth: c.date_of_birth || null,\n })),\n last_active_child_id: ctx.user?.last_active_child_id || null,\n active_child_hint,\n recent_messages: ctx.recent_messages || [],\n recent_interventions: ctx.recent_interventions || [],\n history_summary: ctx.user?.history_summary || null,\n failed_by_category: Array.isArray(ctx.failed_by_category) ? ctx.failed_by_category : [],\n message: text,\n is_voice: !!parse.is_voice,\n};\n\n// --- heuristics ---\nconst msgLen = text.length;\nconst recentTurns = msgs.length;\nconst conf = inboundLast?.transcription_confidence; // often undefined with your current model\norchestrator_input.offer_voice_note = (msgLen >= 320) || (recentTurns >= 12);\norchestrator_input.voice_low_confidence = (typeof conf === 'number') && (conf < 0.7);\n\nreturn [{\n json: {\n phone_number: parse.phone_number,\n orchestrator_input,\n }\n}];"
},
"id": "f8594a25-3c1a-4133-a2d4-d82c772a006e",
"name": "Resolve child + build",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-576,
528
],
"notes": "Purpose: Choose likely child; build clean LLM input.\nUse: DB-first selection: match exact inbound by provider message id (WA: whatsapp_id; TG: telegram_message_id stored in the same compat slot), else latest inbound, else webhook text.\nOutput orchestrator_input = { lang, children[], last_active_child_id, active_child_hint?, failed_by_category, history_summary, recent_messages, recent_interventions, message, is_voice, offer_voice_note, voice_low_confidence }.\nMeta: Heuristic offer_voice_note = (message length \u2265 320) OR (recent_messages length \u2265 12). voice_low_confidence wired to transcription confidence when present. Emits p_phone identity (\u201ctg:\u201d for TG) for downstream RPCs."
},
{
"parameters": {
"language": "JavaScript",
"jsCode": "const ctx = $node['Supabase: get_context'].json;\nconst src = $json; // orchestrator raw JSON\n\n// Verify draft (Code) \u2014 replace the final return\nreturn [{\n json: {\n draft: $json,\n context: {\n children: $node['Supabase: get_context'].json.children || [],\n last_active_child_id: $node['Supabase: get_context'].json.user?.last_active_child_id || null,\n failed_by_category: $node['Supabase: get_context'].json.failed_by_category || [],\n language: $node['Supabase: get_context'].json.user?.preferred_language || 'en',\n history_summary: $node['Supabase: get_context'].json.user?.history_summary || null\n }\n }\n}];"
},
"id": "59041dae-d85e-4426-9503-636b2dfea143",
"name": "Verify draft",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-112,
528
],
"notes": "Purpose: Package context for Verifier.\nUse: { draft, context:{ children[], last_active_child_id, failed_by_category[], language, history_summary } }."
},
{
"parameters": {
"method": "POST",
"url": "https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/apply_updates",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "supabaseApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{$json}}",
"options": {
"response": {
"response": {
"responseFormat": "text"
}
}
}
},
"id": "574c1bc8-f674-4bff-a537-ef920ddd7ae5",
"name": "Supabase: apply_updates",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
2704,
544
],
"credentials": {
"supabaseApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Atomic persist (children upsert, outbound insert, issues/interventions, follow-up queue).\nUse: De-dupes follow-ups on write; outbound idempotent via client_msg_id.\nMeta: Strict v2 envelope."
},
{
"parameters": {
"method": "POST",
"url": "https://graph.facebook.com/v22.0/742665365605812/messages",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "whatsAppApi",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{\n ({\n messaging_product: 'whatsapp',\n to: $node['Format (strict)'].json.phone_number,\n type: 'text',\n text: {\n preview_url: false,\n body: $node['Format (strict)'].json.response\n }\n })\n}}",
"options": {}
},
"id": "ca610657-d719-4592-813a-6a114d16fdf6",
"name": "WhatsApp: send text",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 3,
"position": [
2912,
544
],
"credentials": {
"whatsAppApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Send reply to parent.\nUse: Reads phone_number and response from Formatter.\nMeta: Templates are used only by the WA Follow-up Sender."
},
{
"parameters": {
"jsCode": "// ===== Keep your existing identity + reply guardrails =====\nconst phone = $json.phone_number;\nconst reply = String($json.response || '');\nif (!phone) throw new Error('Build p_updates: missing phone');\nif (!reply) throw new Error('Build p_updates: outbound.text missing');\n\nconst waId = $node['Parse payload'].json.whatsapp_id || 'no-waid';\n\n// Deterministic, no external libs:\nfunction tinyHash(s){\n let h = 0; for (let i=0;i<s.length;i++) h = (h*31 + s.charCodeAt(i)) >>> 0;\n return h.toString(16).padStart(8,'0');\n}\nconst client_msg_id = `${waId}:v2:${tinyHash(reply)}`;\n\n// ===== NEW: tiny helpers for safe, short parameters =====\nfunction cleanText(v, max = 60) {\n if (v == null) return '';\n let s = String(v).trim().replace(/\\s+/g, ' ');\n if (s.length > max) s = s.slice(0, max - 1) + '\u2026';\n return s;\n}\nfunction prettyCategory(slug) {\n if (!slug) return '';\n const s = String(slug).toLowerCase();\n // very small map so we never send empty topic when category is known\n const map = {\n 'sleep': 'sleep',\n 'sleep.naps': 'naps',\n 'feeding': 'feeding',\n 'feeding.solids': 'solids',\n 'behavior': 'behavior',\n 'behavior.tantrums': 'tantrums',\n 'potty': 'potty training',\n 'health': 'health',\n 'development': 'development',\n 'routine': 'routine',\n 'childcare': 'childcare',\n 'travel': 'travel',\n 'screen_time': 'screen time'\n };\n return map[s] || s.split('.').pop();\n}\nfunction pickTopic() {\n // Prefer any short/structured fields the Verifier/Formatter passes through,\n // then fall back to the freeform text, then category label.\n const t =\n $json.topic_short ??\n $json.topic_label ??\n $json.topic_freeform ??\n prettyCategory($json.category_slug);\n return cleanText(t, 60);\n}\nfunction pickChildName() {\n // Prefer normalized child object from Verifier/Formatter\n const n = $json.child?.name\n || ($json.children && Array.isArray($json.children) ? $json.children[0]?.name : null)\n || null;\n return cleanText(n, 40);\n}\nfunction pickWinShort() {\n // If Verifier/Formatter ever provides a concise success label\n const w = $json.win_short ?? $json.flags?.win_short ?? null;\n // fall back to a generic, still safe\n return cleanText(w || 'that win', 50);\n}\n\n// ===== Build base p_updates (unchanged) =====\nconst p_updates = {\n outbound: {\n client_msg_id,\n text: reply,\n lang: $json.lang || 'en',\n child_id: Array.isArray($json.interventions) && $json.interventions[0]?.child_id\n ? $json.interventions[0].child_id\n : null\n }\n};\n\n// children upsert (optional)\nif (Array.isArray($json.children) && $json.children.length) {\n p_updates.children = $json.children;\n}\n\n// interventions (optional)\nif (Array.isArray($json.interventions) && $json.interventions.length) {\n p_updates.interventions = $json.interventions.map(iv => ({\n category_slug: iv.category_slug,\n technique_slug: iv.technique_slug ?? null,\n solution: iv.solution ?? null,\n expected_outcome: iv.expected_outcome ?? null,\n child_id: iv.child_id ?? null\n }));\n}\n\n// ===== Follow-up (optional) \u2014 prefer ISO from Policy Clamp/Formatter =====\nconst templateName = $json.template_name || null;\nconst sfIso = $json.followup_scheduled_for_iso || null; // set in Formatter if present\nconst hasHours = $json.followup_hours != null;\n\nif (templateName && (sfIso || hasHours)) {\n const hours = Number($json.followup_hours ?? 12);\n const scheduled_for = sfIso || new Date(Date.now() + hours * 3600 * 1000).toISOString();\n\n // Start from any parameters already provided upstream; we only fill what\u2019s missing.\n const params = Object.assign({}, $json.followup_params || {});\n const childName = pickChildName();\n const topic = pickTopic();\n\n // ===== NEW: deterministic mapping per template (fills only if missing) =====\n switch (templateName) {\n case 'open_loop_nudge':\n if (params.topic == null || params.topic === '') params.topic = topic;\n break;\n\n case 'check_in_generic':\n if (params.topic == null || params.topic === '') params.topic = topic;\n if (params.child_name == null || params.child_name === '') params.child_name = childName;\n break;\n\n case 'reengage_weekly':\n if (params.child_name == null || params.child_name === '') params.child_name = childName;\n if (params.topic_hint == null || params.topic_hint === '') params.topic_hint = topic;\n break;\n\n case 'success_celebration':\n if (params.child_name == null || params.child_name === '') params.child_name = childName;\n if (params.win_short == null || params.win_short === '') params.win_short = pickWinShort();\n break;\n\n // keep other templates working (no-op) \u2014 add cases here when you add new ones\n default:\n break;\n }\n\n // Final safety: never send undefined; use empty strings for absent params\n for (const k of Object.keys(params)) {\n if (params[k] == null) params[k] = '';\n }\n\n p_updates.followup = {\n create: true,\n template_name: templateName,\n scheduled_for,\n language: $json.lang || 'en',\n parameters: params,\n };\n}\n\nreturn [{ json: { p_phone: phone, p_updates } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2512,
544
],
"id": "a1c6df27-4288-4679-8a09-312d180ad731",
"name": "Build p_updates",
"notes": "Purpose: Create v2 payload for DB.\nUse: { p_phone, p_updates: { outbound:{ client_msg_id, text, lang, child_id?, provider }, children?, interventions?, followup? } }.\nMeta: Deterministic client_msg_id derived from provider message id + reply. Guards on missing outbound.text."
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "gpt-4o",
"mode": "list",
"cachedResultName": "GPT-4O"
},
"messages": {
"values": [
{
"content": "You are **Bubu**, a warm, practical parenting companion.\n\nINPUT (JSON from the previous node):\n- lang\n- children[]: { id, name, date_of_birth|null }\n- last_active_child_id, active_child_hint\n- recent_messages[] (last 14 days)\n- recent_interventions[]\n- failed_by_category[]: [{ child_id, category_slug, slugs:[technique_slug] }]\n- history_summary: string|null\n- message: parent\u2019s current message\n- is_voice: boolean\n- offer_voice_note: boolean\n- voice_low_confidence: boolean\n\nBEHAVIOR:\n- Be brief, kind, and actionable. 40\u201390 words. Use a child\u2019s name only if present in `children[]`, `active_child_hint`, or explicitly in `message`.\n- Do not guess a name. If unknown, say \u201cyour child\u201d.\n- One clarifier max if advice is blocked; ask one crisp question.\n- Avoid techniques listed in `failed_by_category` for that child/category.\n- Use `history_summary` only for continuity (don\u2019t restate it).\n- Detect medical red flags; if present, set flags.medical_red_flag=true and add a short safety line (no diagnosis).\n- If a check-in will help, suggest it and set a follow-up.\n- Tone: warm coach; 2\u20134 concrete steps.\n- Language = input message language; else reuse last known.\n\nIMPORTANT OUTPUT RULES:\n- Never invent names/ages. If a field isn\u2019t known, output **null**.\n- Only include `intervention` when you give a concrete plan.\n- Only include `child_update` when the parent introduces a *new* child.\n- Only include `schedule_followup` when you explicitly want a check-in; parameters must not contain a child_name unless it is known.\n\nOUTPUT \u2014 return ONLY this JSON (no markdown, no extra text):\n{\n \"reply\": string, // 40\u201390 words, plain text\n \"lang\": \"en\" | \"es\" | \"it\" | \"...\",\n \"topic_freeform\": string|null,\n \"category_slug\": \"sleep\"|\"sleep.naps\"|\"feeding.solids\"|\"feeding\"|\"behavior.tantrums\"|\"behavior\"|\"potty\"|\"health\"|\"development\"|\"routine\"|\"childcare\"|\"travel\"|\"screen_time\"|null,\n\n \"child\": { \"id\": string|null, \"name\": string|null },\n\n \"intervention\": {\n \"technique_slug\": string,\n \"solution\": string, // 2\u20134 short steps\n \"expected_outcome\": string|null\n } | null,\n\n \"clarification\": { \"ask\": boolean, \"question\": string|null },\n\n \"schedule_followup\": {\n \"create\": boolean,\n \"template_name\": string|null,\n \"scheduled_for_hours\": number|null,\n \"parameters\": { \"child_name\": string|null } | null\n },\n\n \"child_update\": {\n \"add_or_update\": boolean,\n \"name\": string|null,\n \"date_of_birth\": string|null,\n \"approx_age_months\": number|null\n } | null,\n\n \"flags\": { \"medical_red_flag\": boolean, \"upsell\": \"never\"|\"maybe\"|\"now\" }\n}",
"role": "system"
},
{
"content": "={{ JSON.stringify($node[\"Resolve child + build\"].json.orchestrator_input) }}"
}
]
},
"jsonOutput": true,
"options": {
"temperature": 0.2
}
},
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [
-416,
528
],
"id": "55b8a696-d801-40ab-98e2-036738ff9817",
"name": "OpenAI: Orchestrator",
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Draft human reply + structure.\nUse: Returns reply, lang, topic_freeform, category_slug?, child{id|name?}, intervention?, clarification?, schedule_followup?, child_update?, flags.\nMeta: \u201cPlan + Probe\u201d; \u22645 lines; use history_summary for continuity (don\u2019t echo). Never guess child names; unknowns must be null. May append a single non-gated \u201csend a quick voice note\u201d line when offer_voice_note=true."
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "gpt-4o",
"mode": "list",
"cachedResultName": "GPT-4O"
},
"messages": {
"values": [
{
"content": "You are a deterministic verifier/fixer that outputs ONE JSON object named pipeline_ready.\n\nRules (do all):\n- Reply length: \u22645 lines. If longer, condense to \u22645 lines.\n- Safety line: add ONLY if flags.medical_red_flag=true.\n- Resolve child in this order: (1) explicit id; (2) match name in context.children; (3) context.last_active_child_id; else null.\n- Interventions require BOTH category_slug and child.id; otherwise set intervention=null for this turn.\n- If intervention.technique_slug exists in context.failed_by_category for this child+category, replace with a reasonable alternative or drop technique_slug but keep solution.\n- If category_slug=null, try a simple keyword map from topic_freeform; if still null, do NOT include an intervention.\n- If child_update.add_or_update=true in the draft, include children:[{name, date_of_birth|null, approx_age_months|null}].\n- You may use context.history_summary only to resolve names/continuity. Never echo it.\n\nOutput EXACTLY:\n{\n \"pipeline_ready\": {\n \"reply\": string,\n \"lang\": string,\n \"topic_freeform\": string|null,\n \"category_slug\": string|null,\n \"child\": {\"id\": \"uuid|null\", \"name\": \"string|null\"},\n \"children\": [{\"name\":\"string\",\"date_of_birth\":null|\"YYYY-MM-DD\",\"approx_age_months\":number|null}]|null,\n \"intervention\": {\"technique_slug\":\"string|null\",\"solution\":\"string|null\",\"expected_outcome\":\"string|null\"}|null,\n \"schedule_followup\": {\"create\":bool,\"template_name\":\"string|null\",\"scheduled_for_hours\":number|null,\"parameters\":object}|null,\n \"flags\": {\"medical_red_flag\":bool}\n }\n}\nReturn ONLY pipeline_ready. No commentary.",
"role": "system"
},
{
"content": "=Context:\n{{JSON.stringify($json.context)}}\nModel output to verify:\n{{JSON.stringify($json.draft)}}\n\nReturn ONLY pipeline_ready."
}
]
},
"jsonOutput": true,
"options": {
"maxTokens": 600,
"temperature": 0,
"topP": 1
}
},
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [
64,
528
],
"id": "1b11084a-704b-42c6-a3d2-dfdc65b82018",
"name": "OpenAI: Verifier",
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Normalize to DB-ready pipeline_ready.\nUse: Returns pipeline_ready = { reply, lang, topic_freeform?, category_slug?, child{id|name?}, children[]?, intervention?, schedule_followup?{create, template_name?, scheduled_for_hours?, parameters?}, flags }.\nMeta: Enforces \u22645 lines; safety line only on flag; interventions require category_slug + child.id. Unknown names remain null."
},
{
"parameters": {
"modelId": {
"__rl": true,
"value": "gpt-4o",
"mode": "list",
"cachedResultName": "GPT-4O"
},
"messages": {
"values": [
{
"content": "Produce a compact, factual relationship summary of the last 10\u201315 turns (child focus, topics, techniques tried, outcomes, next check-in). \u22641100 characters hard cap. No fluff, no advice, no emojis. Return plain text only.",
"role": "system"
},
{
"content": "=Phone: {{$node[\"Resolve child + build\"].json.phone_number}}\nRecent messages:\n{{JSON.stringify($node[\"Supabase: get_context\"].json.recent_messages || [])}}\nRecent interventions:\n{{JSON.stringify($node[\"Supabase: get_context\"].json.recent_interventions || [])}}"
}
]
},
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [
3088,
544
],
"id": "d506f3b3-2bd7-4ad4-bea6-249754f42e6f",
"name": "OpenAI: Summarizer",
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Short factual relationship summary.\nUse: \u22641100 chars; no advice."
},
{
"parameters": {},
"type": "n8n-nodes-base.errorTrigger",
"typeVersion": 1,
"position": [
-2320,
720
],
"id": "8919edef-8a83-4f51-86c5-e4a29d4f12b7",
"name": "Error Trigger",
"notes": "Purpose: Capture workflow failure.\nUse: Error Trigger \u2192 Code (build log) \u2192 log_event(p_phone, \u2018workflow_error\u2019, metadata).\nMeta: Column is metadata (not meta). No customer message."
},
{
"parameters": {
"method": "POST",
"url": "https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/log_event",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "supabaseApi",
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "p_phone",
"value": "={{$json.phone_number}}"
},
{
"name": "p_type",
"value": "={{$json.event_type}}"
},
{
"name": "p_meta",
"value": "={{ JSON.stringify($json.meta) }}"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-1952,
720
],
"id": "dc1c1c34-7ac7-4d6f-904e-7ea1c75f1e8c",
"name": "log_event",
"credentials": {
"supabaseApi": {
"name": "<your credential>"
}
}
},
{
"parameters": {
"method": "POST",
"url": "=https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/update_user_summary",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "supabaseApi",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"bodyParameters": {
"parameters": [
{
"name": "p_phone",
"value": "={{$node[\"Resolve child + build\"].json.phone_number}}"
},
{
"name": "p_summary",
"value": "={{$json.message.content}}"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
3376,
544
],
"id": "e1eb3779-40fb-4b81-aff8-8393448712eb",
"name": "Update Summary",
"credentials": {
"supabaseApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Persist history_summary + last_summary_at.\nUse: { p_phone, p_summary }.\nMeta: SECURITY DEFINER; anon key."
},
{
"parameters": {
"jsCode": "// Error Trigger payload\nconst exec = $json.execution || {};\nconst run = exec.data || {};\nconst runData = run?.resultData?.runData || {};\nconst wf = exec.workflow || {};\nconst err = exec.error || {};\n\nlet phone = null;\n// Heuristic: scan runData for a phone number from any node output\nouter:\nfor (const nodeName of Object.keys(runData)) {\n for (const task of (runData[nodeName] || [])) {\n for (const conn of (task?.data?.main || [])) {\n for (const item of (conn || [])) {\n const j = item?.json || {};\n const candidate = j.phone_number || j.p_phone || j.to || j.from;\n if (candidate && String(candidate).trim()) { phone = String(candidate).trim(); break outer; }\n }\n }\n }\n}\n\nconst trunc = (s, n) => (s ? String(s).slice(0, n) : null);\n\nreturn {\n phone_number: phone || 'unknown',\n event_type: 'workflow_error',\n meta: {\n workflow_id: wf.id || null,\n workflow_name: wf.name || null,\n run_id: exec.id || null,\n failed_node: exec.node?.name || null,\n error_message: trunc(err.message, 500),\n error_stack: trunc(err.stack, 1000),\n started_at: run.startedAt || null,\n stopped_at: run.stoppedAt || null,\n },\n};"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2128,
720
],
"id": "c60dde97-fb60-461c-a796-9a5472f81032",
"name": "Build Log"
},
{
"parameters": {
"jsCode": "// ---- Formatter (strict) ----\n\nconst phone = $node['Resolve child + build'].json.phone_number;\nif (!phone) throw new Error('Formatter: missing phone_number');\n\nconst pr = $json.pipeline_ready || $node['Guard: Verifier JSON'].json.pipeline_ready;\nif (!pr || !pr.reply) throw new Error('Formatter: pipeline_ready.reply missing');\n\n// 1) Clamp reply to \u22645 lines\nlet text = String(pr.reply).replace(/\\s+\\n/g, '\\n').trim()\n .split('\\n').slice(0, 5).join('\\n');\nif (!text) throw new Error('Formatter: empty reply after clamp');\n\n// 2) Topic \u2192 category fallback\nfunction mapTopicToCategorySlug(topic) {\n if (!topic) return null;\n const t = String(topic).toLowerCase();\n if (/(sleep|nap|bedtime|regression|wake window|early waking)/.test(t)) return 'sleep';\n if (/(feed|breast|bottle|formula|latch|pumping|reflux|spit up|colic)/.test(t)) return 'feeding';\n if (/(solids|wean|weaning|pur\u00e9e|blw|baby-led)/.test(t)) return 'feeding.solids';\n if (/(potty|toilet|pee|poo|accident)/.test(t)) return 'potty';\n if (/(fever|cough|rash|diarrhea|vomit|sick|ill)/.test(t)) return 'health';\n if (/(routine|schedule|morning|bedtime routine)/.test(t)) return 'routine';\n if (/(tantrum|meltdown|biting|hitting|aggression|defiance|boundary|discipline|scream)/.test(t)) return 'behavior.tantrums';\n if (/(milestone|rolling|crawling|walking|talking)/.test(t)) return 'development';\n if (/(screen|tv|tablet|youtube)/.test(t)) return 'screen_time';\n if (/(travel|flight|jet lag|car seat)/.test(t)) return 'travel';\n return null;\n}\nconst category_slug = pr.category_slug || mapTopicToCategorySlug(pr.topic_freeform);\n\n// 3) Build output\nconst out = {\n phone_number: phone,\n response: text,\n lang: pr.lang || 'en',\n};\n\n// 4) Children passthrough\nif (Array.isArray(pr.children) && pr.children.length) {\n out.children = pr.children.map(c => {\n const name = String(c.name || '').trim();\n if (!name) return null;\n const hasDOB = !!c.date_of_birth;\n return {\n name,\n date_of_birth: hasDOB ? c.date_of_birth : null,\n approx_age_months: c.approx_age_months ?? null,\n dob_precision: hasDOB ? 'exact' : 'approx',\n };\n }).filter(Boolean);\n}\n\n// 5) Interventions (if both category & child.id)\nif (category_slug && pr.child && pr.child.id && pr.intervention) {\n const { technique_slug = null, solution = null, expected_outcome = null } = pr.intervention;\n if (technique_slug || solution) {\n out.interventions = [{\n category_slug,\n technique_slug,\n solution,\n expected_outcome,\n child_id: pr.child.id,\n }];\n }\n}\n\n// 6) Follow-up passthrough\nif (pr.schedule_followup && pr.schedule_followup.create) {\n out.followup_hours = pr.schedule_followup.scheduled_for_hours ?? null;\n out.template_name = pr.schedule_followup.template_name || null;\n out.followup_params = pr.schedule_followup.parameters || {};\n if (pr.schedule_followup.scheduled_for_iso) {\n out.followup_scheduled_for_iso = pr.schedule_followup.scheduled_for_iso;\n }\n}\n\nreturn [{ json: out }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2272,
544
],
"id": "b14a80a8-0e73-4305-b053-c6f19419b034",
"name": "Format (strict)",
"notes": "Purpose: Convert pipeline_ready \u2192 channel-specific envelope + DB-safe struct.\nUse: Clamps to \u22645 lines; surfaces followup_scheduled_for_iso when present.\nMeta:\n\u2022 WA: plain text copy for \u201csend now\u201d; template fields only for scheduled follow-ups."
},
{
"parameters": {
"jsCode": "// Normalize Verifier output to { pipeline_ready: {...} } no matter the shape\n\nconst raw = $json;\nlet pr = null;\n\n// 1) Ideal: already at root\nif (raw && raw.pipeline_ready) pr = raw.pipeline_ready;\n\n// 2) n8n \"full\" message shape\nif (!pr && raw && raw.message && raw.message.content && raw.message.content.pipeline_ready) {\n pr = raw.message.content.pipeline_ready;\n}\n\n// 3) Array of choices/messages\nif (!pr && Array.isArray(raw) && raw[0]?.message?.content?.pipeline_ready) {\n pr = raw[0].message.content.pipeline_ready;\n}\n\n// 4) Occasionally it's under .content directly\nif (!pr && raw && raw.content && raw.content.pipeline_ready) {\n pr = raw.content.pipeline_ready;\n}\n\n// 5) Last resort: if the model returned the bare object (no wrapper)\n// we accept it if it looks like a pipeline_ready (has reply/lang)\nif (!pr && raw && typeof raw === 'object' && raw.reply && raw.lang) {\n pr = raw;\n}\n\nif (!pr || typeof pr !== 'object') {\n const keys = raw && typeof raw === 'object' ? Object.keys(raw) : typeof raw;\n throw new Error(`Verifier: missing pipeline_ready object (got: ${JSON.stringify(keys)})`);\n}\n\n// Minimal shape checks (fail fast)\nfor (const k of ['reply','lang','child','flags']) {\n if (!(k in pr)) throw new Error(`Verifier: pipeline_ready.${k} missing`);\n}\nif (!pr.reply) throw new Error('Verifier: empty reply');\n\n// Output normalized shape for downstream nodes\nreturn [{ json: { pipeline_ready: pr } }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
384,
528
],
"id": "bc33b3c3-0c19-4525-9212-9318ded01292",
"name": "Guard: Verifier JSON",
"notes": "Purpose: Normalize Verifier output regardless of LLM shape.\nUse: Guarantees downstream input is { pipeline_ready:{ reply, lang, \u2026 } }.\nMeta: Hard-fails if reply/lang missing."
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "3a08c22b-3c91-46f6-9922-a37062a905b5",
"leftValue": "={{$json.is_voice}}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
-1904,
496
],
"id": "5ff365d0-a7ad-4322-9942-58d7667525b2",
"name": "is_voice?",
"notes": "Purpose: Ignore delivery/status webhooks and non-message updates.\nUse: If no content and no media, stop."
},
{
"parameters": {
"url": "={{ 'https://graph.facebook.com/v20.0/' + $node[\"Parse payload\"].json.media_id }}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "whatsAppApi",
"options": {
"response": {
"response": {
"responseFormat": "json"
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-1696,
352
],
"id": "8cd7c456-5256-4ff5-9029-241efbb782f8",
"name": "WA Get Media URL",
"credentials": {
"whatsAppApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Resolve temporary download URL from media_id.\nUse: Graph API; returns { url, mime_type, file_size, id }."
},
{
"parameters": {
"resource": "audio",
"operation": "transcribe",
"options": {}
},
"type": "@n8n/n8n-nodes-langchain.openAi",
"typeVersion": 1.8,
"position": [
-1328,
352
],
"id": "557366b9-69b1-48fe-a7c6-7356ca3f5598",
"name": "Transcribe a recording",
"credentials": {
"openAiApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Convert audio to text.\nUse: Input = binary; returns { text, seconds }.\nMeta: transcription_confidence may be null; field retained."
},
{
"parameters": {
"jsCode": "// earlier fields from your Parse payload node\nconst parse = $node[\"Parse payload\"].json;\n\n// transcription payload from the previous node\nconst tr = $json;\n\n// text (robust)\nconst text =\n (tr.text ??\n tr.data?.text ??\n tr.output?.text ??\n tr.choices?.[0]?.message?.content ??\n \"\").toString();\n\n// confidence: not returned by this model \u2192 keep null (or compute later if you switch models)\nlet conf = null;\n\n// duration: map from usage.seconds (fallbacks kept just in case)\nconst duration =\n tr.usage?.seconds ??\n tr.duration ??\n tr.data?.duration ??\n null;\n\nreturn [\n {\n json: {\n phone_number: parse.phone_number,\n whatsapp_id: parse.whatsapp_id,\n type: \"text\",\n content: text.trim(),\n is_voice: true,\n media_id: parse.media_id ?? null,\n transcription_confidence: conf, // stays null (expected)\n audio_seconds: duration, // now 4 in your example\n timestamp: parse.timestamp,\n },\n },\n];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-1152,
352
],
"id": "f8741c07-c0cd-4f60-8889-546c19b29e74",
"name": "Build transcribed message",
"notes": "Purpose: Standardize transcription payload to \u201ctext-like\u201d inbound.\nUse: Output matches the channel\u2019s normalized schema with is_voice=true and audio_seconds set."
},
{
"parameters": {
"url": "={{$json.url}}",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "whatsAppApi",
"options": {
"response": {
"response": {
"responseFormat": "file"
}
}
}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
-1520,
352
],
"id": "29e671ad-0b0f-4351-a8e6-8047ae87c0e6",
"name": "WA Download Media",
"credentials": {
"whatsAppApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Download audio as binary.\nUse: GET url \u2192 binary property data.\nMeta: Use provider credential."
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "bd1b5110-5885-45f4-bc69-4be896612165",
"leftValue": "={{ !!$json.pipeline_ready?.schedule_followup?.create }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
576,
528
],
"id": "6ddb5b68-4f65-4fa0-b099-954b98e1ff45",
"name": "Has followup?"
},
{
"parameters": {
"jsCode": "// 0) Early-drop if timing disallowed (string or boolean)\nconst ta = $json.timing_allowed;\nif (ta === false || ta === 'false') return [];\n\n// 1) Inputs\nconst out = items[0].json;\nconst pr = out.pipeline_ready || {};\nconst sf = pr.schedule_followup || {};\nif (!sf.create) return items;\n\n// 2) Normalize LLM template \u2192 snake_case + map synonyms\nconst raw = String(sf.template_name || '')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '_')\n .replace(/^_|_$/g, '');\n\nconst MAP = {\n // generic check-ins\n 'check_in': 'check_in_generic',\n 'checkin': 'check_in_generic',\n 'check_in_on_sleep_schedule': 'check_in_generic',\n 'sleep_check_in': 'check_in_generic',\n 'sleep_progress_check': 'check_in_generic',\n\n // nudges\n 'open_loop': 'open_loop_nudge',\n 'nudge': 'open_loop_nudge',\n\n // weekly re-engage\n 'weekly_check_in': 'reengage_weekly',\n 'reengage': 'reengage_weekly',\n\n // celebration\n 'celebrate_success': 'success_celebration',\n 'win_celebration': 'success_celebration',\n};\n\nlet template = MAP[raw] || raw;\n\n// 3) Allowed templates (as in your Meta list)\nconst ALLOW = new Set([\n 'check_in_generic',\n 'open_loop_nudge',\n 'reengage_weekly',\n 'success_celebration',\n 'bubu_test_basic',\n 'hello_world',\n]);\n\n// Fallback if LLM name not recognized\nif (!ALLOW.has(template)) {\n const hrs = Number(sf.scheduled_for_hours ?? 24);\n template = hrs >= 72 ? 'reengage_weekly' : 'check_in_generic';\n}\n\n// 4) Caps / recency guard\nconst sent1d = Number(out.sent_1d || 0) > 0;\nconst sent7d = Number(out.sent_7d || 0) >= 3;\nconst recent = !!out.recent_inbound;\nconst isCritical = template === 'clarify_blocker'; // none of your current templates, kept for future\nif (sent1d || sent7d || (recent && !isCritical)) {\n pr.schedule_followup = { create: false };\n out.pipeline_ready = pr;\n return [{ json: out }];\n}\n\n// 5) Ensure parameters (topic & child_name)\nconst topic =\n pr.topic_freeform || pr.category_slug || sf.parameters?.topic || 'your goal';\nconst childName =\n pr.child?.name || pr.children?.[0]?.name || sf.parameters?.child_name || 'your child';\nsf.parameters = { ...(sf.parameters || {}), topic, child_name: childName };\n\n// 6) Timing source: prefer RPC (Adopt node) \u2192 fallback to candidate\nconst whenIso =\n out.followup_scheduled_for_iso ??\n sf.scheduled_for_iso ??\n out.scheduled_for_candidate_iso ?? null;\n\n// 7) Write back\npr.schedule_followup = {\n ...sf,\n create: true,\n template_name: template,\n language: sf.language || 'en',\n scheduled_for_iso: whenIso,\n};\nout.pipeline_ready = pr;\n\nreturn [{ json: out }];"
},
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1936,
368
],
"id": "c40c6671-7040-4b84-b59a-9e6b6d6a6b8b",
"name": "Follow-up Policy Clamp",
"notes": "Purpose: Early-drop follow-ups disallowed by timing clamp/opt-out.\nUse: if ($json.timing_allowed === false) return []; (rest of policy unchanged).\nMeta: Prevents enqueue when cap_24h/cap_7d/opted_out/in_quiet_hours; optionally log timing_reason for observability."
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "3c490698-789f-4043-b2d9-1b9fed8d8451",
"name": "_now_iso",
"value": "={{ $now.toUTC().toISO() }}",
"type": "string"
},
{
"id": "25db1f7c-3e30-4ae1-a1e3-ae7d159fe0c6",
"name": "_cutoff_2h",
"value": "={{ $now.minus({ hours: 2 }).toUTC().toISO() }}",
"type": "string"
},
{
"id": "d124e936-1ffc-463d-961f-e841fe6ee4e8",
"name": "_cutoff_1d",
"value": "={{ $now.minus({ days: 1 }).toUTC().toISO() }}",
"type": "string"
},
{
"id": "ce116adc-9335-4c1e-867f-a5cbae1a8e97",
"name": "_cutoff_7d",
"value": "={{ $now.minus({ days: 7 }).toUTC().toISO() }}",
"type": "string"
},
{
"id": "216a8523-a044-489f-bf7c-2721365ad7a1",
"name": "_candidate_utc",
"value": "={{ $now.plus({ hours: $json.pipeline_ready?.schedule_followup?.scheduled_for_hours ?? 12 }).toUTC().toISO() }}",
"type": "string"
},
{
"id": "5426e7ce-24a0-4afa-8807-e39fccab38b2",
"name": "scheduled_for_candidate_iso",
"value": "={{ $now.plus({ hours: $json.pipeline_ready?.schedule_followup?.scheduled_for_hours ?? 12 }).toUTC().toISO() }}",
"type": "string"
},
{
"id": "712db744-bbd0-4253-a819-81b0180c1a88",
"name": "phone_number",
"value": "={{ $json.phone_number || $node[\"Parse payload\"].json.phone_number }}",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
832,
384
],
"id": "16b8d26f-4667-4a01-aa1b-c7baf3dd895a",
"name": "Time helpers + clamp candidate",
"notes": "Purpose: Produce a candidate timestamp only (no per-user clamping here).\nUse: Output scheduled_for_candidate_iso (e.g., now+24h, \u201ctoday 18:00\u201d).\nMeta: Remove old quiet-hours logic\u2014final ISO is set downstream via the RPC result."
},
{
"parameters": {
"method": "POST",
"url": "=https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/followup_clamp_inputs",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "supabaseApi",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"p_phone\": \"{{$json.phone_number}}\",\n \"p_cutoff_2h\": \"{{$json._cutoff_2h}}\",\n \"p_cutoff_1d\": \"{{$json._cutoff_1d}}\",\n \"p_cutoff_7d\": \"{{$json._cutoff_7d}}\"\n}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1104,
208
],
"id": "6e2fa884-dbff-4770-bbb7-23a1ffbed48b",
"name": "Supabase: Clamp inputs",
"credentials": {
"supabaseApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Provide sender stop-rules data.\nUse: Input phone + cutoffs \u2192 { recent_inbound, sent_1d, sent_7d }.\nMeta: DB is single source of truth for recency and caps."
},
{
"parameters": {
"method": "POST",
"url": "=https://idewetjagvjpdjvqlpvl.supabase.co/rest/v1/rpc/rpc_followup_timing_clamp",
"authentication": "predefinedCredentialType",
"nodeCredentialType": "supabaseApi",
"sendHeaders": true,
"headerParameters": {
"parameters": [
{
"name": "Content-Type",
"value": "application/json"
}
]
},
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"p_phone_number\": \"={{ $json.phone_number || $node['Parse payload'].json.phone_number }}\",\n \"p_candidate\": \"={{ $json.scheduled_for_candidate_iso }}\",\n \"p_cap_24h\": 1,\n \"p_cap_7d\": 3,\n \"p_jitter_minutes\": 7\n}",
"options": {}
},
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1200,
384
],
"id": "04639200-9f76-47e7-a50e-a73018be004a",
"name": "Supabase: timing_clamp (RPC)",
"credentials": {
"supabaseApi": {
"name": "<your credential>"
}
},
"notes": "Purpose: Convert a candidate send time into a per-user allowed time using timezone + quiet hours, with caps & jitter.\nUse: Calls rpc_followup_timing_clamp with p_phone_number + p_candidate; returns { allowed, next_allowed, reason }.\nMeta: Auth via SERVICE_ROLE; don\u2019t \u201cContinue On Fail\u201d; idempotent (read-only); safe to call multiple times."
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "2e79888b-0ad0-4377-8dc5-c2228476b199",
"name": "timing_allowed",
"value": "={{ $json.allowed }}",
"type": "boolean"
},
{
"id": "522c7623-3f9c-4cd8-8959-1c399e4e1bcf",
"name": "timing_reason",
"value": "={{ $json.reason }}",
"type": "string"
},
{
"id": "a5fa35c7-bf17-490e-99fe-8c90e5c52699",
"name": "followup_scheduled_for_iso",
"value": "={{ $json.next_allowed }}",
"type": "string"
}
]
},
"options": {}
},
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1456,
384
],
"id": "2bf02bbe-083c-4242-ae9c-937e0446d29d",
"name": "Adopt timing clamp result",
"notes": "Purpose: Normalize RPC output for downstream nodes.\nUse: Exposes timing_allowed, timing_reason, and sets followup_scheduled_for_iso = next_allowed.\nMeta: Hard-fails if allowed=true but next_allowed missing; keeps a single, final ISO for the queue."
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {
"clashHandling": {
"values": {
"resolveClash": "preferInput1"
}
},
"includeUnpaired": false
}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
1472,
192
],
"id": "29a44b46-dff0-4922-9ce6-20547393d23d",
"name": "Merge A",
"notes": "Purpose: Carry both (A) pipeline_ready + clamped ISO and (B) RPC caps in one item.\nUse: Deep-merge, left-bias."
},
{
"parameters": {
"mode": "combine",
"combineBy": "combineByPosition",
"options": {}
},
"type": "n8n-nodes-base.merge",
"typeVersion": 3.2,
"position": [
1728,
368
],
"id": "0a6b83b7-3a20-43b2-a58e-44beb0156273",
"name": "Merge B",
"notes": "Purpose: Combine upstream payload + caps info + timing-clamp result into one item.\nUse: Input 1 = Merge A (existing), Input 2 = Adopt timing clamp result.\nMeta: Mode = Merge by Index; preserves all fields; no transforms\u2014just unions the three streams."
}
],
"connections": {
"Webhook (GET)": {
"main": [
[
{
"node": "Verify (GET)",
"type": "main",
"index": 0
}
]
]
},
"Verify (GET)": {
"main": [
[
{
"node": "Respond (GET)",
"type": "main",
"index": 0
}
]
]
},
"Webhook (POST)": {
"main": [
[
{
"node": "Parse payload",
"type": "main",
"index": 0
}
]
]
},
"Parse payload": {
"main": [
[
{
"node": "is_voice?",
"type": "main",
"index": 0
}
]
]
},
"Skip?": {
"main": [
[],
[
{
"node": "Supabase: record_inbound",
"type": "main",
"index": 0
}
]
]
},
"Supabase: record_inbound": {
"main": [
[
{
"node": "Supabase: get_context",
"type": "main",
"index": 0
}
]
]
},
"Supabase: get_context": {
"main": [
[
{
"node": "Resolve child + build",
"type": "main",
"index": 0
}
]
]
},
"Resolve child + build": {
"main": [
[
{
"node": "OpenAI: Orchestrator",
"type": "main",
"index": 0
}
]
]
},
"Verify draft": {
"main": [
[
{
"node": "OpenAI: Verifier",
"type": "main",
"index": 0
}
]
]
},
"Supabase: apply_updates": {
"main": [
[
{
"node": "WhatsApp: send text",
"type": "main",
"index": 0
}
]
]
},
"Build p_updates": {
"main": [
[
{
"node": "Supabase: apply_updates",
"type": "main",
"index": 0
}
]
]
},
"OpenAI: Orchestrator": {
"main": [
[
{
"node": "Verify draft",
"type": "main",
"index": 0
}
]
]
},
"OpenAI: Verifier": {
"main": [
[
{
"node": "Guard: Verifier JSON",
"type": "main",
"index": 0
}
]
]
},
"WhatsApp: send text": {
"main": [
[
{
"node": "OpenAI: Summarizer",
"type": "main",
"index": 0
}
]
]
},
"OpenAI: Summarizer": {
"main": [
[
{
"node": "Update Summary",
"type": "main",
"index": 0
}
]
]
},
"Error Trigger": {
"main": [
[
{
"node": "Build Log",
"type": "main",
"index": 0
}
]
]
},
"Build Log": {
"main": [
[
{
"node": "log_event",
"type": "main",
"index": 0
}
]
]
},
"Format (strict)": {
"main": [
[
{
"node": "Build p_updates",
"type": "main",
"index": 0
}
]
]
},
"Guard: Verifier JSON": {
"main": [
[
{
"node": "Has followup?",
"type": "main",
"index": 0
}
]
]
},
"is_voice?": {
"main": [
[
{
"node": "WA Get Media URL",
"type": "main",
"index": 0
}
],
[
{
"node": "Skip?",
"type": "main",
"index": 0
}
]
]
},
"WA Get Media URL": {
"main": [
[
{
"node": "WA Download Media",
"type": "main",
"index": 0
}
]
]
},
"Transcribe a recording": {
"main": [
[
{
"node": "Build transcribed message",
"type": "main",
"index": 0
}
]
]
},
"Build transcribed message": {
"main": [
[
{
"node": "Supabase: record_inbound",
"type": "main",
"index": 0
}
]
]
},
"WA Download Media": {
"main": [
[
{
"node": "Transcribe a recording",
"type": "main",
"index": 0
}
]
]
},
"Has followup?": {
"main": [
[
{
"node": "Time helpers + clamp candidate",
"type": "main",
"index": 0
}
],
[
{
"node": "Format (strict)",
"type": "main",
"index": 0
}
]
]
},
"Time helpers + clamp candidate": {
"main": [
[
{
"node": "Supabase: Clamp inputs",
"type": "main",
Credentials you'll need
Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.
openAiApisupabaseApiwhatsAppApi
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Bubu WhatsApp Companion. Uses httpRequest, openAi, errorTrigger. Webhook trigger; 35 nodes.
Source: https://gist.github.com/kooshanajmi86/46b74d6b3020c5ecbc11686fad7eeb87 — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Bubu Telegram Companion. Uses httpRequest, openAi, errorTrigger, telegram. Webhook trigger; 31 nodes.
Automate your inbound lead qualification pipeline by enriching raw lead data, scoring it with AI, and instantly creating follow-up tasks for your sales team. 🎯🤖 This workflow receives new leads via we
This powerful n8n automation workflow is designed to execute advanced B2B lead enrichment and hyper-personalization for cold email outreach. By orchestrating a complex chain of data scraping, AI analy
Eu Clara – Funil Kiwify Completo. Uses postgres, openAi, httpRequest, gmail. Webhook trigger; 70 nodes.
This workflow bridges the gap between raw product data and revenue sales tools. It automates the entire Product Qualified Lead (PQL) lifecycle—from real-time intent routing to churn prevention—reducin