AutomationFlowsData & Sheets › Wm-followup-cron.n8n-import

Wm-followup-cron.n8n-import

Wm-Followup-Cron.N8N-Import. Uses redis, httpRequest, postgres. Scheduled trigger; 18 nodes.

Cron / scheduled trigger★★★★☆ complexity18 nodesRedisHTTP RequestPostgres
Data & Sheets Trigger: Cron / scheduled Nodes: 18 Complexity: ★★★★☆ Added:

This workflow follows the HTTP Request → Postgres 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 →

Download .json
{
  "nodes": [
    {
      "id": "e8229901-e0c7-4d82-bef7-38349ec39268",
      "name": "Cron 15min",
      "type": "n8n-nodes-base.scheduleTrigger",
      "typeVersion": 1.2,
      "position": [
        0,
        300
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "minutes",
              "minutesInterval": 15
            }
          ]
        }
      }
    },
    {
      "id": "255effa3-7343-45a5-b6ed-b2919b0a7b16",
      "name": "Window Check SP",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "// SP = UTC-3. Window 6h-22h59 SP \u2192 9h-01h59 UTC\nconst now = new Date();\nconst spHour = (now.getUTCHours() - 3 + 24) % 24;\nif (spHour < 6 || spHour > 22) {\n  return [{ json: { ok: true, skipped: true, reason: 'outside_window', sp_hour: spHour } }];\n}\nreturn [{ json: { ok: true, now_ms: Date.now(), sp_hour: spHour } }];"
      }
    },
    {
      "id": "1a93f10b-dc01-4361-904d-f60233546af0",
      "name": "IF In Window",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        440,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c_in",
              "leftValue": "={{ !$json.skipped }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "f0ebc5e6-a092-45b4-b426-d9a42d24c926",
      "name": "Redis KEYS leads",
      "type": "n8n-nodes-base.redis",
      "typeVersion": 1,
      "position": [
        660,
        200
      ],
      "parameters": {
        "operation": "keys",
        "keyPattern": "lead:*:state",
        "options": {}
      },
      "credentials": {
        "redis": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "97429ec4-a509-4470-9a0f-7261bfb59be7",
      "name": "Split Keys",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        200
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const items = $input.all();\nconst out = [];\nfor (const it of items) {\n  const j = it.json || {};\n  for (const key of Object.keys(j)) {\n    if (/^lead:\\d+:state$/.test(key)) {\n      out.push({ json: { key } });\n    }\n  }\n}\nreturn out;"
      }
    },
    {
      "id": "19759644-5da1-49c0-8776-6a8930ebffe3",
      "name": "Extract Phone",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1100,
        200
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const k = String($json.key || '');\nconst m = k.match(/^lead:(\\d+):state$/);\nif (!m) return { json: { skip: true, key: k } };\nreturn { json: { tel: m[1], key: k } };"
      }
    },
    {
      "id": "af9088b0-8bf1-49b0-a5ca-c67ed95e014d",
      "name": "Get State",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        1320,
        200
      ],
      "parameters": {
        "method": "POST",
        "url": "https://ia-n8n.clalha.easypanel.host/webhook/wm-se-v3",
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={{ JSON.stringify({ tel: $json.tel, action: 'get' }) }}",
        "options": {
          "timeout": 10000
        }
      }
    },
    {
      "id": "a4bdfd35-bcee-4dee-80b8-b382a0d88289",
      "name": "Check Eligibility",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1540,
        200
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const st = $json.state;\nconst tel = $json.tel;\nconst now_ms = Date.now();\nconst TERMINAL = ['aprovado','bloqueado_humano','encerrado_inativo','encaminhado_humano'];\nconst FOLLOWUP_STEPS = ['apresentando','aguardando_aulas','aguardando_form','aguardando_revisao'];\n\nif (!st || !st.journey) return { json: { tel, skip: true, reason: 'no_state' } };\nif (!st.bot || st.bot.enabled === false) return { json: { tel, skip: true, reason: 'bot_disabled' } };\nif ((st.bot.paused_until_ms || 0) > now_ms) return { json: { tel, skip: true, reason: 'paused' } };\nif (TERMINAL.includes(st.journey.step)) return { json: { tel, skip: true, reason: 'terminal', step: st.journey.step } };\nif (!FOLLOWUP_STEPS.includes(st.journey.step)) return { json: { tel, skip: true, reason: 'no_rules_for_step', step: st.journey.step } };\n\nreturn { json: { tel, skip: false, step: st.journey.step, entered_at_ms: st.journey.entered_at_ms, state: st, now_ms, name_for_use: (st.lead_profile && st.lead_profile.name_for_use) || null } };"
      }
    },
    {
      "id": "cb439935-7cc7-4b45-8db9-509b7294e000",
      "name": "IF Eligible",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        1760,
        200
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c_elig",
              "leftValue": "={{ !$json.skip }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "aeebea64-4222-4b3f-93e8-da953e7433fb",
      "name": "Get Next Rule",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        1980,
        100
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "=WITH last_attempt AS (\n  SELECT COALESCE(MAX(rule_attempt), -1) AS a\n  FROM wm_followup_log\n  WHERE phone = '{{ $json.tel }}' AND journey_step = '{{ $json.step }}'\n)\nSELECT r.id, r.attempt, r.delay_ms, r.message_intent, r.fallback_text, r.terminal_step, r.apply_tag\nFROM wm_followup_rules r, last_attempt\nWHERE r.journey_step = '{{ $json.step }}' AND r.attempt = last_attempt.a + 1 AND r.active = true\nLIMIT 1;",
        "options": {}
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "7913a18d-12e5-478f-9360-392abb908702",
      "name": "Compute Fire",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2200,
        100
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const lead = $('Check Eligibility').item.json;\nconst rule = $json;\nif (!rule || !rule.id) return { json: { ...lead, skip: true, reason: 'no_more_rules' } };\nconst due_at = Number(lead.entered_at_ms || 0) + Number(rule.delay_ms);\nif (due_at > lead.now_ms) return { json: { ...lead, skip: true, reason: 'not_due_yet', due_at } };\n// Fallback text with tally link placeholder\nlet fallback = String(rule.fallback_text || '');\nreturn { json: { ...lead, skip: false, rule, due_at, fallback_text: fallback } };"
      }
    },
    {
      "id": "c528eb0a-23a6-4817-9acc-a65465093626",
      "name": "IF Fire",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2420,
        100
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "c_fire",
              "leftValue": "={{ !$json.skip }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "3fced5bb-2dbb-4c98-965b-dac7bf50d252",
      "name": "Get Tally Base",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        2640,
        0
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "SELECT value#>>'{}' AS base FROM wm_config WHERE key='links.tally_base';",
        "options": {}
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      }
    },
    {
      "id": "e29f55c7-98f2-4072-832e-cef2136e813b",
      "name": "Merge Tally",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2860,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const up = $('Compute Fire').item.json;\nconst tally_base = $json.base || '';\nconst tally_url = tally_base + '?telefone=' + encodeURIComponent(up.tel);\n// Replace {tally} placeholder in fallback_text\nconst fallback_with_link = String(up.fallback_text || '').replace(/\\{tally\\}/g, tally_url);\nreturn { json: { ...up, tally_url, fallback_text: fallback_with_link } };"
      }
    },
    {
      "id": "6bd29cb5-2258-4dcf-93ee-8980464903cd",
      "name": "Followup LLM",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        3080,
        0
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.openai.com/v1/chat/completions",
        "authentication": "predefinedCredentialType",
        "nodeCredentialType": "openAiApi",
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={{ JSON.stringify({\n  model: 'gpt-4.1',\n  temperature: 0.8,\n  max_tokens: 200,\n  messages: [\n    { role: 'system', content: `Voc\u00ea \u00e9 o Maestro Wellington, da W.Music. Gere uma mensagem de follow-up CURTA pro WhatsApp, tom evang\u00e9lico natural, 1\u00aa pessoa, m\u00e1x 2 par\u00e1grafos separados por linha em branco. N\u00c3O repita fallback_text literalmente \u2014 reescreva no seu estilo pessoal.\\n\\nIntent: ${$json.rule.message_intent}\\nStep atual: ${$json.step}\\nTentativa: ${$json.rule.attempt}\\nNome do lead: ${$json.name_for_use || 'irm\u00e3o'}\\n\\nSe a intent mencionar link do question\u00e1rio/tally, use EXATAMENTE: ${$json.tally_url}\\n\\nNUNCA mencione pre\u00e7os ou regras internas. Se a intent \u00e9 'final_encaminhamento' ou 'final_inatividade_*', escreva mensagem de encerramento sem prometer retorno automatizado.` },\n    { role: 'user', content: `Fallback pra inspira\u00e7\u00e3o: ${$json.fallback_text}` }\n  ]\n}) }}",
        "options": {
          "timeout": 20000
        }
      },
      "credentials": {
        "openAiApi": {
          "name": "<your credential>"
        }
      },
      "continueOnFail": true
    },
    {
      "id": "83a38023-6874-4ff7-b3ed-1eb77c9f6709",
      "name": "Pick Text",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3300,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const up = $('Merge Tally').item.json;\nlet text = up.fallback_text;\ntry {\n  const llm = $json.choices && $json.choices[0] && $json.choices[0].message && $json.choices[0].message.content;\n  if (llm && llm.trim().length > 5) text = llm.trim();\n} catch(e) {}\nreturn { json: { ...up, final_text: text } };"
      }
    },
    {
      "id": "295d058e-8bff-4c96-811c-36698ebf488e",
      "name": "Execute Followup",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3520,
        0
      ],
      "parameters": {
        "mode": "runOnceForEachItem",
        "jsCode": "const up = $json;\nconst STAGE_URL = 'https://ia-n8n.clalha.easypanel.host/webhook/wm-actions-v3';\nconst STATE_URL = 'https://ia-n8n.clalha.easypanel.host/webhook/wm-se-v3';\nconst hdrs = {'Content-Type': 'application/json'};\nconst results = {};\n\ntry {\n  // 1. Send humanized text\n  const chunks = String(up.final_text).split(/\\n\\n+/).map(c => c.trim()).filter(Boolean);\n  for (const chunk of chunks) {\n    const delay = Math.min(600 + chunk.length * 35, 6000) + Math.floor(Math.random() * 400);\n    await new Promise(r => setTimeout(r, delay));\n    await this.helpers.httpRequest({\n      method: 'POST', url: STAGE_URL,\n      body: {kind: 'send_text', tel: up.tel, params: {text: chunk}},\n      json: true, headers: hdrs\n    });\n  }\n  results.chunks = chunks.length;\n\n  // 2. Terminal transition if rule says so\n  if (up.rule.terminal_step) {\n    await this.helpers.httpRequest({\n      method: 'POST', url: STATE_URL,\n      body: {tel: up.tel, action: 'transition', params: {intent: 'followup_final'}},\n      json: true, headers: hdrs\n    });\n    results.terminal_transition = up.rule.terminal_step;\n  }\n\n  return { json: { ok: true, tel: up.tel, step: up.step, attempt: up.rule.attempt, rule_id: up.rule.id, results } };\n} catch(e) {\n  return { json: { ok: false, tel: up.tel, error: String(e).slice(0, 300) } };\n}"
      }
    },
    {
      "id": "72f02a72-d8e5-44eb-a492-537caf2b6db8",
      "name": "Log Followup",
      "type": "n8n-nodes-base.postgres",
      "typeVersion": 2.5,
      "position": [
        3740,
        0
      ],
      "parameters": {
        "operation": "executeQuery",
        "query": "=INSERT INTO wm_followup_log (phone, journey_step, rule_id, rule_attempt, message_intent, sent_text, result) VALUES ('{{ $json.tel }}', '{{ $json.step }}', {{ $json.rule_id }}, {{ $json.attempt }}, '{{ $('Merge Tally').item.json.rule.message_intent }}', '{{ $('Merge Tally').item.json.final_text ? $('Merge Tally').item.json.final_text.replace(/\\\\/g, '\\\\\\\\').replace(/'/g, \"''\") : '' }}', '{{ JSON.stringify($json.results || {}).replace(/'/g, \"''\") }}'::jsonb);",
        "options": {}
      },
      "credentials": {
        "postgres": {
          "name": "<your credential>"
        }
      },
      "continueOnFail": true
    }
  ],
  "connections": {
    "Cron 15min": {
      "main": [
        [
          {
            "node": "Window Check SP",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Window Check SP": {
      "main": [
        [
          {
            "node": "IF In Window",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF In Window": {
      "main": [
        [
          {
            "node": "Redis KEYS leads",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Redis KEYS leads": {
      "main": [
        [
          {
            "node": "Split Keys",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Split Keys": {
      "main": [
        [
          {
            "node": "Extract Phone",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract Phone": {
      "main": [
        [
          {
            "node": "Get State",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get State": {
      "main": [
        [
          {
            "node": "Check Eligibility",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Eligibility": {
      "main": [
        [
          {
            "node": "IF Eligible",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Eligible": {
      "main": [
        [
          {
            "node": "Get Next Rule",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Get Next Rule": {
      "main": [
        [
          {
            "node": "Compute Fire",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Compute Fire": {
      "main": [
        [
          {
            "node": "IF Fire",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF Fire": {
      "main": [
        [
          {
            "node": "Get Tally Base",
            "type": "main",
            "index": 0
          }
        ],
        []
      ]
    },
    "Get Tally Base": {
      "main": [
        [
          {
            "node": "Merge Tally",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Tally": {
      "main": [
        [
          {
            "node": "Followup LLM",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Followup LLM": {
      "main": [
        [
          {
            "node": "Pick Text",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick Text": {
      "main": [
        [
          {
            "node": "Execute Followup",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Execute Followup": {
      "main": [
        [
          {
            "node": "Log Followup",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

Each integration node will prompt for credentials when you import. We strip credential IDs before publishing — you'll add your own.

Pro

For the full experience including quality scoring and batch install features for each workflow upgrade to Pro

About this workflow

Wm-Followup-Cron.N8N-Import. Uses redis, httpRequest, postgres. Scheduled trigger; 18 nodes.

Source: https://gist.github.com/bruunofco/b119d8f345e6003b7dd97bf6e9066c2c — original creator credit. Request a take-down →

More Data & Sheets workflows → · Browse all categories →

Related workflows

Workflows that share integrations, category, or trigger type with this one. All free to copy and import.

Data & Sheets

Disparador 1.8. Uses itemLists, postgres, emailSend, httpRequest. Scheduled trigger; 85 nodes.

Item Lists, Postgres, Email Send +1
Data & Sheets

Agendamiento_v2. Uses n8n-nodes-evolution-api, redis, httpRequest, executeWorkflowTrigger. Event-driven trigger; 59 nodes.

N8N Nodes Evolution Api, Redis, HTTP Request +3
Data & Sheets

Cancelacion_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 46 nodes.

Execute Workflow Trigger, Redis, HTTP Request +3
Data & Sheets

공유회_알림톡_크론. Uses postgres, httpRequest, n8n-nodes-solapi. Scheduled trigger; 39 nodes.

Postgres, HTTP Request, N8N Nodes Solapi
Data & Sheets

QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.

Postgres, Postgres Trigger, HTTP Request