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 →
{
"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.
openAiApipostgresredis
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 →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
Disparador 1.8. Uses itemLists, postgres, emailSend, httpRequest. Scheduled trigger; 85 nodes.
Agendamiento_v2. Uses n8n-nodes-evolution-api, redis, httpRequest, executeWorkflowTrigger. Event-driven trigger; 59 nodes.
Cancelacion_v2. Uses executeWorkflowTrigger, redis, httpRequest, n8n-nodes-evolution-api. Event-driven trigger; 46 nodes.
공유회_알림톡_크론. Uses postgres, httpRequest, n8n-nodes-solapi. Scheduled trigger; 39 nodes.
QuepasaAutomatic. Uses postgres, postgresTrigger, httpRequest. Scheduled trigger; 39 nodes.