This workflow follows the HTTP Request → Slack 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 →
{
"name": "Seller Follow-Up Engine (Enhanced)",
"nodes": [
{
"parameters": {
"method": "POST",
"url": "https://YOUR_CONVEX_DEPLOYMENT.convex.site/api/template-send",
"authentication": "genericCredentialType",
"genericAuthType": "httpHeaderAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ lead_id: $('Store Message Data').item.json.leadId, phone: $('Store Message Data').item.json.phone, template_id: $('Store Message Data').item.json.templateId }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
}
},
"id": "2814c3b9-c69f-4bfc-807e-8369f243ff63",
"name": "Record to Convex (Follow-Up)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2176,
10128
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"select": "channel",
"channelId": {
"__rl": true,
"value": "C0ABWBR67JL",
"mode": "list",
"cachedResultName": "test"
},
"text": "=\u2705 Follow-Up Engine completed batch\n\u2022 Sent: {{ $json.totalSent || 0 }} SMS\n\u2022 Errors: {{ $json.errors || 0 }}\n\u2022 Time: {{ new Date().toLocaleString() }}",
"otherOptions": {}
},
"type": "n8n-nodes-base.slack",
"typeVersion": 2.4,
"position": [
1088,
10128
],
"id": "f406d877-0f35-4157-b016-ca2861afff39",
"name": "Slack Success1",
"onError": "continueRegularOutput"
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "cronExpression",
"expression": "0 9,10,11,12,14,15,16,17,18 * * 1-6"
}
]
}
},
"id": "df1a332f-0577-48af-b54d-c62f1836b13c",
"name": "Schedule Trigger",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
96,
10864
]
},
{
"parameters": {
"jsCode": "// ============================================\n// SELLER FOLLOW-UP ENGINE - SPEC COMPLIANT v4.0\n// From: Follow_Up_Engine_Dev_Spec_v2.pdf\n// \n// HARD RULES (Section 9):\n// - NEVER send before 8 AM or after 8 PM EST\n// - NEVER send on Sunday\n// - Saturday: 10 AM - 6 PM only\n// \n// SEND WINDOWS (randomized):\n// - Morning: 8:30 AM - 10:30 AM\n// - Midday: 11:30 AM - 1:30 PM\n// - Afternoon: 2:30 PM - 4:30 PM\n// - Evening: 5:30 PM - 7:30 PM\n// ============================================\n\nconst now = new Date();\n\n// Get EST time properly\nconst estString = now.toLocaleString('en-US', { timeZone: 'America/New_York' });\nconst estDate = new Date(estString);\nconst currentHourEST = estDate.getHours();\nconst currentMinuteEST = estDate.getMinutes();\nconst dayOfWeekEST = estDate.getDay(); // 0=Sun, 6=Sat\n\n// Phone Number Configuration\nconst PHONE_CONFIG = {\n '330': [\n { id: 'phon_jIteklsdr2tUmnb09NjGuQYiPY1WylLCYAeIAyrZMe0', number: '+15555550100' },\n { id: 'phon_uFcxzUFdhi1ZQl1BHaqoANc2fNacsViMtUIvYpMAV7j', number: '+15555550100' },\n { id: 'phon_v8P5GhHOKwzCSR0hgdUHrnF4DUFmyby45uVtFouI3qe', number: '+15555550100' }\n ],\n '440': [\n { id: 'phon_Hcd8Gnd5UVhOs6sd8RaS15krqSRFTKI3qrVmBNhpWd8', number: '+15555550100' },\n { id: 'phon_mGR9Sverf72aPeVovjGChbNtfH6g2gmS2hEAlgzbLjq', number: '+15555550100' }\n ],\n '216': [\n { id: 'phon_GPHgzIPuuBsCBtw5u2K0rVreNa5LWcqYjZZ2JgAEQz3', number: '+15555550100' }\n ],\n 'default': { id: 'phon_jIteklsdr2tUmnb09NjGuQYiPY1WylLCYAeIAyrZMe0', number: '+15555550100' }\n};\n\n// ZIP to Area Code Mapping\nconst ZIP_TO_AREA_CODE = {\n // BRRRR (Akron) - 330\n '44302': '330', '44303': '330', '44306': '330', '44310': '330',\n '44312': '330', '44313': '330', '44314': '330', '44319': '330', '44320': '330',\n // BRRRR (Canton) - 330\n '44703': '330', '44705': '330', '44708': '330', '44709': '330',\n '44710': '330', '44714': '330',\n // BRRRR (Other) - 330\n '44646': '330', '44647': '330', '44203': '330', '44224': '330',\n '44232': '330', '44685': '330',\n // High ARV (West) - 440\n '44136': '440', '44149': '440', '44212': '440', '44256': '440',\n '44141': '440', '44281': '440', '44147': '440', '44133': '440',\n // High ARV (Canton) - 330\n '44718': '330', '44720': '330', '44721': '330'\n};\n\n// HARD RULES CHECK\nlet shouldRun = true;\nlet skipReason = '';\n\n// Rule 1: NEVER on Sunday\nif (dayOfWeekEST === 0) {\n shouldRun = false;\n skipReason = 'Sunday - no sends allowed';\n}\n\n// Rule 2: Saturday restricted hours (10 AM - 6 PM EST only)\nelse if (dayOfWeekEST === 6) {\n if (currentHourEST < 10 || currentHourEST >= 18) {\n shouldRun = false;\n skipReason = 'Saturday outside 10AM-6PM EST window';\n }\n}\n\n// Rule 3: Weekdays - 8 AM to 8 PM EST only\nelse {\n if (currentHourEST < 8 || currentHourEST >= 20) {\n shouldRun = false;\n skipReason = 'Outside 8AM-8PM EST window';\n }\n}\n\n// Determine current window for randomization\nlet currentWindow = 'none';\nif (currentHourEST >= 8 && currentHourEST < 11) currentWindow = 'morning';\nelse if (currentHourEST >= 11 && currentHourEST < 14) currentWindow = 'midday';\nelse if (currentHourEST >= 14 && currentHourEST < 17) currentWindow = 'afternoon';\nelse if (currentHourEST >= 17 && currentHourEST < 20) currentWindow = 'evening';\n\nreturn [{\n json: {\n shouldRun,\n skipReason,\n currentWindow,\n currentTime: now.toISOString(),\n currentTimeEST: estString,\n currentHourEST,\n currentMinuteEST,\n dayOfWeekEST,\n dayName: ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'][dayOfWeekEST],\n phoneConfig: PHONE_CONFIG,\n zipToAreaCode: ZIP_TO_AREA_CODE\n }\n}];"
},
"id": "4eb6a2e1-8c5e-49a1-9466-20bfd4cce693",
"name": "Initialize Config",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
96,
10608
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"id": "should-run",
"leftValue": "={{ $json.shouldRun }}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "0d4507ff-e632-4906-8d3a-3536dbd1d913",
"name": "Should Run?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
272,
10608
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"options": {
"reset": false
}
},
"id": "94d74b38-7506-4225-898e-390c1d779c4d",
"name": "Rate Limiter (1 per sec)",
"type": "n8n-nodes-base.splitInBatches",
"typeVersion": 3,
"position": [
1072,
10592
]
},
{
"parameters": {
"amount": 1
},
"id": "8c6dcc89-d864-4e54-b9bd-40a496e2adde",
"name": "1s Delay",
"type": "n8n-nodes-base.wait",
"typeVersion": 1.1,
"position": [
1808,
10352
]
},
{
"parameters": {
"method": "POST",
"url": "https://api.close.com/api/v1/activity/sms/",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"lead_id\": \"{{ $json.leadId }}\",\n \"contact_id\": \"{{ $json.contactId }}\",\n \"local_phone\": \"{{ $json.assignedPhone }}\",\n \"remote_phone\": \"{{ $json.phone }}\",\n \"text\": {{ JSON.stringify($json.messageToSend) }},\n \"direction\": \"outbound\",\n \"status\": \"outbox\"\n}",
"options": {
"response": {
"response": {
"fullResponse": true,
"neverError": true,
"responseFormat": "json"
}
},
"timeout": 30000
}
},
"id": "cda2be08-0072-4a6c-8917-f5049a82707b",
"name": "Send SMS via Close",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1968,
10352
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"onError": "continueRegularOutput"
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"id": "sms-success",
"leftValue": "={{ $json.statusCode }}",
"rightValue": 200,
"operator": {
"type": "number",
"operation": "equals"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "019844f0-5a3d-4232-9527-05741419480a",
"name": "SMS Sent OK?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
2144,
10336
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// CALCULATE NEXT FOLLOW-UP v7.3 - Fixed data reference\n// After SMS send, $input.item.json is Close API response\n// Must reference Store Message Data node for original lead data\n\nconst inputItem = $('Store Message Data').first().json;\n\nif (!inputItem) {\n console.log('No data from Store Message Data node');\n return [{ json: { error: true, message: 'Missing upstream data' } }];\n}\n\n// Get sequence position from the stored data\nconst textBeingSentNow = inputItem.sequencePosition || inputItem._queue?.sequence_position || 1;\n\nif (!textBeingSentNow || textBeingSentNow < 1 || textBeingSentNow > 8) {\n console.log('Invalid sequence position:', textBeingSentNow);\n return [{ json: { error: true, message: 'Invalid sequence position' } }];\n}\n\nconsole.log('Processing Text #' + textBeingSentNow + ' for lead: ' + inputItem.leadId);\n\nconst currentPosition = textBeingSentNow;\nconst nextTextNumber = currentPosition + 1;\n\nconst SEQUENCE_HOURS = {\n 2: 6, 3: 36, 4: 144, 5: 204, 6: 288, 7: 420, 8: 612\n};\n\nconst hoursUntilNext = SEQUENCE_HOURS[nextTextNumber] || 168;\nconst now = new Date();\nconst nextSendDate = new Date(now.getTime() + (hoursUntilNext * 60 * 60 * 1000));\nconst sequenceComplete = nextTextNumber > 8;\n\nlet newPipelineStage;\nif (currentPosition <= 3) newPipelineStage = 'Blitz';\nelse if (currentPosition <= 6) newPipelineStage = 'Extended';\nelse newPipelineStage = 'Nurture';\n\nreturn [{\n json: {\n leadId: inputItem.leadId || inputItem._queue?.lead_id,\n leadName: inputItem.firstName || inputItem._queue?.first_name || '',\n phone: inputItem.phone || inputItem._queue?.phone || '',\n address: inputItem.address || inputItem._queue?.address || '',\n city: inputItem.city || inputItem._queue?.city || '',\n zip: inputItem.zip || inputItem._queue?.zip || '',\n newPosition: currentPosition,\n nextTextNumber: nextTextNumber,\n nextSendDate: sequenceComplete ? null : nextSendDate.toISOString(),\n lastContacted: now.toISOString(),\n newPipelineStage: newPipelineStage,\n sequenceComplete: sequenceComplete,\n listingType: inputItem.listingType || inputItem._queue?.listing_type || 'BRRRR',\n tier: inputItem.tier || inputItem._queue?.tier || '',\n _queue: inputItem._queue || {},\n _debug: { textBeingSentNow, currentPosition, nextTextNumber, hoursUntilNext, version: '7.3', dataSource: 'Store Message Data' }\n }\n}];"
},
"id": "7b54b9cb-6b26-4ef6-bea2-5c15d3f3bdd8",
"name": "Calculate Next Follow-Up",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2368,
10320
]
},
{
"parameters": {
"method": "PUT",
"url": "=https://api.close.com/api/v1/lead/{{ $json.leadId }}/",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"custom.cf_4k0rrTQ6pQUytyLp3T0lAthZ7AEFUKyRbndn59Hsu8U\": {{ $json.newPosition }},\n \"custom.cf_d8OZm5MmRSchNbeJF4KXmrAm3JpjVscpyMKrAQ6ek9r\": \"{{ $json.nextSendDate || '' }}\",\n \"custom.cf_QgUEmGIweiQG45RF5GD09JJNCyhcIZf2uNqOL8zT0sl\": \"{{ $json.newPipelineStage }}\",\n \"custom.cf_mLLAy4ctJLqn2zhjECML593u8vs4lsIDtMjTQpHudtf\": \"{{ $json.lastContacted }}\",\n \"custom.cf_vrVajMz5zvcWRSxoflOq1AbfxWT12lVCvQZD04hIPvG\": \"outbound\"\n}",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
}
},
"id": "0a7d13cc-7e74-4a7f-946e-8b91e29465fe",
"name": "Update Lead (Success)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2544,
10320
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// LOG SMS FAILURE v7.3 - Fixed data reference\n// Get error response from current input\nconst errorResponse = $input.item.json;\n\n// Get lead info from Store Message Data\nlet leadInfo = {};\ntry {\n leadInfo = $('Store Message Data').first().json || {};\n} catch(e) {\n console.log('Could not get lead info from Store Message Data');\n}\n\nlet failStats;\ntry {\n const raw = $execution.customData.get('failureStats');\n failStats = raw ? JSON.parse(raw) : { total: 0, failures: [] };\n} catch(e) {\n failStats = { total: 0, failures: [] };\n}\n\nfailStats.total++;\nif (failStats.failures.length < 10) {\n failStats.failures.push({ \n leadId: leadInfo.leadId || 'unknown',\n phone: leadInfo.phone || 'unknown',\n error: JSON.stringify(errorResponse).slice(0, 200) \n });\n}\n\n$execution.customData.set('failureStats', JSON.stringify(failStats));\n\nconsole.log('SMS Failure for lead:', leadInfo.leadId || 'unknown');\n\nreturn [{\n json: {\n status: 'failed',\n leadId: leadInfo.leadId,\n phone: leadInfo.phone,\n error: errorResponse,\n timestamp: new Date().toISOString()\n }\n}];"
},
"id": "b6d34068-124e-481f-9f68-2e6068fe5bd6",
"name": "Log SMS Failure",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2448,
10608
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"url": "https://api.close.com/api/v1/lead/?query=custom.cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW:\"Active Conversation\" AND custom.cf_vrVajMz5zvcWRSxoflOq1AbfxWT12lVCvQZD04hIPvG:\"outbound\"&_fields=id,display_name,contacts,custom&_limit=100",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
}
},
"id": "50c41a6c-451f-4dbe-a9f5-d41646cf1ef6",
"name": "Get Stalled Conversations",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
336,
10160
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// ============================================\n// STALL DETECTION\n// Check for conversations that went cold\n// ============================================\n\nconst response = $input.first().json;\nconst leads = response.data || [];\nconst now = new Date();\n\nconst stalledLeads = [];\n\nfor (const lead of leads) {\n const custom = lead.custom || {};\n const pipelineStage = custom.cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW;\n const lastMsgDirection = custom.cf_vrVajMz5zvcWRSxoflOq1AbfxWT12lVCvQZD04hIPvG;\n const lastMsgAt = custom.cf_mLLAy4ctJLqn2zhjECML593u8vs4lsIDtMjTQpHudtf;\n const responseType = custom.cf_response_type;\n \n // Skip if not in Active Conversation\n if (pipelineStage !== 'Active Conversation') continue;\n \n // Skip if they sent last message\n if (lastMsgDirection !== 'outbound') continue;\n \n // Calculate hours since last message\n if (!lastMsgAt) continue;\n const hoursSince = (now.getTime() - new Date(lastMsgAt).getTime()) / (1000 * 60 * 60);\n \n // Stall if > 24 hours and previous response was positive/question\n if (hoursSince > 24 && ['positive', 'question'].includes(responseType)) {\n stalledLeads.push({\n json: {\n leadId: lead.id,\n leadName: lead.display_name,\n hoursSinceLastMsg: Math.round(hoursSince),\n previousResponseType: responseType,\n action: 'mark_stalled'\n }\n });\n }\n}\n\nif (stalledLeads.length === 0) {\n return [{ json: { noStalledLeads: true } }];\n}\n\nreturn stalledLeads;"
},
"id": "bf6ff907-3b1d-442d-bcc9-3ddf647fede9",
"name": "Detect Stalled Conversations",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
528,
10160
]
},
{
"parameters": {
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"conditions": [
{
"id": "has-stalled",
"leftValue": "={{ $json.leadId }}",
"operator": {
"type": "string",
"operation": "notEmpty",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "2393a189-17b0-40d6-82a6-67e7f5c3cabf",
"name": "Has Stalled?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
704,
10144
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"method": "PUT",
"url": "=https://api.close.com/api/v1/lead/{{ $json.leadId }}/",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"custom.cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW\": \"Stalled\",\n \"custom.cf_stall_detected_at\": \"{{ $now.toISOString() }}\",\n \"custom.cf_QgUEmGIweiQG45RF5GD09JJNCyhcIZf2uNqOL8zT0sl\": \"Re-engagement\",\n \"custom.cf_4k0rrTQ6pQUytyLp3T0lAthZ7AEFUKyRbndn59Hsu8U\": 1,\n \"custom.cf_d8OZm5MmRSchNbeJF4KXmrAm3JpjVscpyMKrAQ6ek9r\": \"{{ new Date(Date.now() + 24*60*60*1000).toISOString() }}\"\n}",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
}
},
"id": "4aa4f903-0bd0-4b7e-a4b3-d3ae3eaa3e32",
"name": "Mark as Stalled",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
944,
10128
],
"retryOnFail": true,
"maxTries": 3,
"waitBetweenTries": 2000,
"onError": "continueRegularOutput"
},
{
"parameters": {
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"combinator": "and",
"conditions": [
{
"id": "phase-transition-check",
"leftValue": "={{ $('Calculate Next Follow-Up').item.json.newPosition }}",
"rightValue": 4,
"operator": {
"type": "number",
"operation": "gte"
}
}
]
},
"options": {}
},
"id": "28850260-2219-4870-81cd-1687b6fc2522",
"name": "Phase Transition?1",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2896,
10560
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "PUT",
"url": "=https://api.close.com/api/v1/lead/{{ $('Calculate Next Follow-Up').item.json.leadId }}",
"authentication": "genericCredentialType",
"genericAuthType": "httpBasicAuth",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "{\n \"status_id\": \"stat_bmHeqaDzp8iU2XGVNMfHS1frlQTG2LaQvymODA7GtoK\"\n}",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 30000
}
},
"id": "d6fc1695-d101-480f-92e4-0dec07575ff8",
"name": "Update Status to Extended1",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2896,
10752
]
},
{
"parameters": {
"jsCode": "// ============================================================================\n// ENHANCED SLACK NOTIFICATION WITH POSITION MONITORING\n// ============================================================================\n\nconst items = $('Rate Limiter (1 per sec)').all();\n\n// Collect statistics\nlet successCount = 0;\nlet failureCount = 0;\nlet positionMismatches = 0;\nconst leads = [];\nconst mismatches = [];\n\nitems.forEach(item => {\n const data = item.json;\n\n // Check if SMS was sent successfully\n const success = data._smsSuccess || false;\n\n if (success) {\n successCount++;\n\n leads.push({\n name: data.leadName || data._queue?.first_name || 'Unknown',\n phone: data.phone || data._queue?.phone || 'Unknown',\n sentPosition: data._queue?.sequence_position,\n newPosition: data.newPosition,\n nextPosition: data.nextTextNumber,\n stage: data.newPipelineStage\n });\n\n // CHECK FOR MISMATCH: If we have debug info\n if (data._debug) {\n const sentPos = data._debug.textBeingSentNow;\n const updatedPos = data.newPosition;\n\n if (sentPos !== updatedPos) {\n positionMismatches++;\n mismatches.push({\n lead: data.leadName || 'Unknown',\n sentPosition: sentPos,\n updatedPosition: updatedPos\n });\n }\n }\n\n } else {\n failureCount++;\n }\n});\n\n// Calculate success rate\nconst totalProcessed = successCount + failureCount;\nconst successRate = totalProcessed > 0 ? ((successCount / totalProcessed) * 100).toFixed(1) : 0;\n\n// Build detailed message\nconst leadsList = leads.slice(0, 10).map(l =>\n ` \u2022 ${l.name}: Text #${l.sentPosition} \u2192 Position ${l.newPosition} (next: ${l.nextPosition || 'complete'}) [${l.stage}]`\n).join('\\n');\n\nconst mismatchList = mismatches.length > 0 ? mismatches.map(m =>\n ` \u26a0\ufe0f ${m.lead}: Sent #${m.sentPosition} but updated to ${m.updatedPosition}`\n).join('\\n') : ' \u2705 No mismatches detected';\n\nconst timestamp = new Date().toISOString();\n\nconst message = `\n\ud83d\udd14 *Follow-Up Engine Completed*\n\n*Summary:*\n\u2705 Sent: ${successCount}\n\u274c Failed: ${failureCount}\n\ud83d\udcca Success Rate: ${successRate}%\n\u23f0 Time: ${timestamp}\n\n*Position Tracking:*\n${positionMismatches > 0 ? '\u26a0\ufe0f' : '\u2705'} Mismatches: ${positionMismatches}\n${mismatchList}\n\n*Recent Sends:*\n${leadsList}\n${leads.length > 10 ? `... and ${leads.length - 10} more` : ''}\n\n${positionMismatches > 0 ? '\\n\u26a0\ufe0f *ACTION REQUIRED:* Position mismatches detected! Check logs.' : ''}\n`;\n\nreturn [{\n json: {\n message: message,\n successCount,\n failureCount,\n successRate,\n positionMismatches,\n totalProcessed,\n leads,\n mismatches,\n timestamp\n }\n}];\n"
},
"id": "4b7df2ec-efb7-4ad0-bfb1-504c6fd99c79",
"name": "Collect Success Stats1",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
528,
10432
]
},
{
"parameters": {
"jsCode": "// TRACK SUCCESS v7.4 - Removed $execution.customData (sandbox blocked)\n// Just pass through data from Calculate Next Follow-Up\n\nconst calcItem = $('Calculate Next Follow-Up').first().json;\n\nif (!calcItem || typeof calcItem !== 'object') {\n console.log('No data from Calculate Next Follow-Up');\n return [{ json: { status: 'error', message: 'Missing upstream data' } }];\n}\n\nconsole.log('Track Success - lead:', calcItem.leadId, '| Position:', calcItem.newPosition);\n\nreturn [{\n json: {\n status: 'success',\n leadId: calcItem.leadId,\n leadName: calcItem.leadName,\n phone: calcItem.phone,\n address: calcItem.address,\n city: calcItem.city,\n zip: calcItem.zip,\n newPosition: calcItem.newPosition,\n nextTextNumber: calcItem.nextTextNumber,\n nextSendDate: calcItem.nextSendDate,\n sequenceComplete: calcItem.sequenceComplete,\n newPipelineStage: calcItem.newPipelineStage,\n listingType: calcItem.listingType,\n tier: calcItem.tier,\n _queue: calcItem._queue,\n _smsSuccess: true,\n _debug: calcItem._debug\n }\n}];"
},
"id": "f9a76bd8-63e5-46f8-b95b-ba9b17657fdf",
"name": "Track Success1",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3120,
10576
]
},
{
"parameters": {
"jsCode": "// Query Convex for follow-ups due NOW\nconst CONVEX_URL = 'https://YOUR_CONVEX_DEPLOYMENT.convex.site';\nconst now = Date.now();\n\ntry {\n const data = await this.helpers.httpRequest({\n method: 'POST',\n url: `${CONVEX_URL}/api/follow-up-queue-due`,\n headers: { 'Content-Type': 'application/json' },\n body: {\n due_before: now,\n status: 'pending',\n limit: 1000\n },\n json: true\n });\n \n const dueLeads = data.leads || [];\n \n if (dueLeads.length === 0) {\n return [{ \n json: { \n noLeads: true, \n totalDue: 0,\n leads: [], \n message: 'No follow-ups due', \n serverTime: new Date().toISOString() \n } \n }];\n }\n \n console.log(`Found ${dueLeads.length} follow-ups due from Convex queue`);\n \n // Return with totalDue at top level for easy visibility\n return [{\n json: {\n totalDue: dueLeads.length,\n message: `${dueLeads.length} follow-ups due`,\n leads: dueLeads,\n serverTime: new Date().toISOString()\n }\n }];\n} catch (error) {\n console.log('Convex query error:', error.message);\n return [{ json: { error: true, totalDue: 0, leads: [], message: error.message } }];\n}"
},
"id": "2ba524dd-6fef-4332-a7fc-4c072e1e2f30",
"name": "Fetch Due from Convex",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
496,
10592
]
},
{
"parameters": {
"jsCode": "// UPDATE CONVEX QUEUE v7.3 - Fixed data reference\nconst CONVEX_URL = 'https://YOUR_CONVEX_DEPLOYMENT.convex.site';\n\n// Reference Track Success1 for proper data\nconst item = $('Track Success1').first().json;\n\nif (!item || typeof item !== 'object') {\n console.log('No data from Track Success1');\n return [{ json: { status: 'error', message: 'Missing upstream data' } }];\n}\n\nconst queueId = item._queue?._id;\nconst leadId = item.leadId || item._queue?.lead_id;\nconst phone = item.phone || item._queue?.phone;\n\nconsole.log('Processing queue update for lead:', leadId);\n\nif (!queueId) {\n console.log('Missing queue ID - skipping');\n return [{ json: { status: 'skipped', reason: 'no_queue_id', leadId } }];\n}\n\nconst results = { queueId, leadId, markedSent: false, addedNext: false, errors: [] };\n\n// STEP 1: Mark current as SENT\ntry {\n await this.helpers.httpRequest({\n method: 'POST',\n url: `${CONVEX_URL}/api/follow-up-queue-complete`,\n headers: { 'Content-Type': 'application/json' },\n body: { queue_id: queueId, status: 'sent', sent_at: Date.now() },\n json: true\n });\n results.markedSent = true;\n} catch (error) {\n results.errors.push(error.message);\n console.log('Failed to mark sent:', error.message);\n}\n\n// STEP 2: Add next follow-up\nif (!item.sequenceComplete && item.nextTextNumber && item.nextTextNumber <= 8) {\n try {\n const allQueue = await this.helpers.httpRequest({\n method: 'GET',\n url: `${CONVEX_URL}/api/follow-up-queue-list?status=pending&limit=100`,\n json: true\n });\n const duplicate = allQueue.find(q => q.lead_id === leadId && q.sequence_position === item.nextTextNumber && q.status === 'pending');\n if (duplicate) {\n results.skippedReason = 'duplicate';\n return [{ json: results }];\n }\n } catch (e) {}\n\n const nextSendTimestamp = new Date(item.nextSendDate).getTime();\n try {\n await this.helpers.httpRequest({\n method: 'POST',\n url: `${CONVEX_URL}/api/follow-up-queue-add`,\n headers: { 'Content-Type': 'application/json' },\n body: {\n lead_id: leadId,\n phone: phone,\n first_name: item._queue?.first_name || item.leadName || '',\n address: item._queue?.address || item.address || '',\n city: item._queue?.city || item.city || '',\n zip: item._queue?.zip || item.zip || '',\n next_send_date: nextSendTimestamp,\n sequence_position: item.nextTextNumber,\n lead_type: 'Seller',\n listing_type: item.listingType || item._queue?.listing_type || 'BRRRR',\n tier: item.tier || item._queue?.tier || '',\n status: 'pending'\n },\n json: true\n });\n results.addedNext = true;\n } catch (error) {\n results.errors.push(error.message);\n }\n} else {\n results.skippedReason = 'sequence_complete';\n}\n\nreturn [{ json: results }];"
},
"id": "update-convex-queue",
"name": "Update Convex Queue",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
3296,
10576
]
},
{
"parameters": {
"jsCode": "// Split Convex leads array into individual n8n items\nconst input = $input.first().json;\nconst leads = input.leads || [];\n\n// If no leads, return EMPTY array - nothing enters the loop\nif (!leads || leads.length === 0) {\n console.log('No follow-ups due - returning empty array');\n return []; // Empty = Rate Limiter goes straight to \"done\" output\n}\n\nconsole.log(`Splitting ${leads.length} leads into individual items`);\n\n// Output each lead as a separate item\nreturn leads.map(queueItem => ({\n json: {\n lead_id: queueItem.lead_id,\n _queue: queueItem\n }\n}));"
},
"id": "fetch-close-details",
"name": "Split Lead Items",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
672,
10592
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"id": "check-id-exists",
"leftValue": "={{ $json.contacts[0].lead_id }}",
"rightValue": "",
"operator": {
"type": "string",
"operation": "exists",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "check-lead-exists",
"name": "Lead Exists in Close?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
1648,
10576
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "POST",
"url": "https://YOUR_CONVEX_DEPLOYMENT.convex.site/api/follow-up-queue-delete",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ lead_id: $('Split Lead Items').item.json.lead_id }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
}
}
},
"id": "delete-stale-from-convex",
"name": "Delete Stale Lead from Convex",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
1872,
10592
]
},
{
"parameters": {
"jsCode": "// LOG DELETED STALE LEAD v7.3 - Fixed input validation\nconst rawInput = $input.item.json;\n\n// Validate input is an object\nif (!rawInput || typeof rawInput !== 'object' || Array.isArray(rawInput)) {\n console.log('Invalid input for deleted lead log');\n return [{ json: { status: 'deleted', data: null, timestamp: new Date().toISOString() } }];\n}\n\nconsole.log('Deleted stale lead:', rawInput.lead_id || 'unknown');\nreturn [{ json: { status: 'deleted', data: rawInput, timestamp: new Date().toISOString() } }];"
},
"id": "log-deleted-lead",
"name": "Log Deleted Stale Lead",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2032,
10592
]
},
{
"parameters": {
"jsCode": "// Build Message v9.3 - Full Safety Filters (matching fls workflow)\nconst CONVEX_URL = 'https://YOUR_CONVEX_DEPLOYMENT.convex.site';\n\n// Helper: Safely extract string from array or string (Close CRM returns arrays for multi-select)\nconst safeStr = (val) => {\n if (Array.isArray(val)) return (val[0] || '').toString();\n return (val || '').toString();\n};\n\n// Helper: Calculate hours since a date\nconst hoursSince = (dateStr) => {\n if (!dateStr) return Infinity;\n const date = new Date(dateStr);\n return (Date.now() - date.getTime()) / (1000 * 60 * 60);\n};\n\n// Get original Convex queue data (with _id)\nlet originalQueue = null;\ntry {\n originalQueue = $('Rate Limiter (1 per sec)').first().json._queue;\n} catch(e) {\n console.log('Could not get original queue data');\n}\n\n// Input validation for Close data\nconst rawInput = $input.item.json;\nlet closeData;\n\nif (!rawInput || typeof rawInput !== 'object' || Array.isArray(rawInput)) {\n console.log('Invalid input - not an object:', typeof rawInput);\n return [{ json: { action: 'SKIP', reason: 'Invalid input data', skipType: 'invalid_input' } }];\n}\n\ncloseData = rawInput;\n\nconsole.log('=== BUILD MESSAGE v9.3 ===');\nconsole.log('Close ID:', closeData.id);\nconsole.log('Queue ID:', originalQueue?._id);\n\nif (!closeData.id) {\n return [{ json: { action: 'SKIP', reason: 'No Close ID', skipType: 'missing_data' } }];\n}\n\n// Extract data from Close CRM\nconst leadId = closeData.id;\nconst displayName = closeData.display_name || '';\nconst firstName = displayName.split(' ')[0] || 'there';\n\n// Get contact info\nconst contact = closeData.contacts && closeData.contacts[0] ? closeData.contacts[0] : {};\nconst contactId = contact.id || null;\nconst phones = contact.phones || [];\nconst phone = phones.length > 0 ? phones[0].phone : '';\n\nif (!phone) {\n return [{ json: { action: 'SKIP', reason: 'No phone', lead_id: leadId, skipType: 'missing_phone' } }];\n}\n\n// Get custom fields from Close (using safeStr for array fields)\nconst custom = closeData.custom || {};\nconst address = custom.address || custom.property_address || safeStr(custom['Property Address']) || safeStr(custom.cf_oYpMjF2AbqjJ9TTnn3BZgCNlductY17okbScR0MvrhD) || originalQueue?.address || 'your property';\nconst city = custom.city || custom.property_city || safeStr(custom['Property City']) || safeStr(custom.cf_V5lJPTCII754eYehZUytaQVlT2AhErHmoNfCRTLrBYp) || originalQueue?.city || 'your area';\nconst zip = custom.zip || custom.property_zip || originalQueue?.zip || '';\nconst sequencePosition = Math.floor(originalQueue?.sequence_position || custom.sequence_position || custom['Sequence Position'] || 1);\nconst listingTypeRaw = safeStr(originalQueue?.listing_type || custom.listing_type || custom['Listing Type'] || 'BRRRR');\nconst tier = originalQueue?.tier || custom.tier || '';\n\nconsole.log('Lead:', firstName, '| Phone:', phone, '| Position:', sequencePosition);\n\n// ========== SAFETY CHECKS (CRITICAL!) ==========\nconst statusLabel = (closeData.status_label || '').toLowerCase();\nconst rawPipeline = custom.cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || custom['Pipeline Stage'] || custom.pipeline_stage || '';\nconst pipelineStage = safeStr(rawPipeline).toLowerCase();\n\n// Get timing fields for safety checks\nconst lastMsgDirection = safeStr(custom.cf_vrVajMz5zvcWRSxoflOq1AbfxWT12lVCvQZD04hIPvG || custom['Last Message Direction'] || '');\nconst lastContactedAt = safeStr(custom.cf_mLLAy4ctJLqn2zhjECML593u8vs4lsIDtMjTQpHudtf || custom['Last Contacted'] || '');\nconst vaLastMsgAt = safeStr(custom.cf_kePjoaKjgduHWIlz5sVnilaTT5SuURgLAsl1D2NhLl9 || custom['VA Last Message At'] || '');\n\nconsole.log('Status:', statusLabel, '| Pipeline:', pipelineStage);\nconsole.log('Last Direction:', lastMsgDirection, '| Last Contact:', lastContactedAt);\n\n// ========== 1. RECENT INBOUND CHECK (48hrs) ==========\nif (lastMsgDirection.toLowerCase() === 'inbound' && hoursSince(lastContactedAt) < 48) {\n console.log('SKIP: Recent inbound -', Math.round(hoursSince(lastContactedAt)), 'hrs ago');\n return [{ json: { action: 'SKIP', reason: `Recent inbound (${Math.round(hoursSince(lastContactedAt))}hrs)`, lead_id: leadId, phone, skipType: 'recent_inbound' } }];\n}\n\n// ========== 2. DOUBLE TAP CHECK (outbound <12hrs) ==========\nif (lastMsgDirection.toLowerCase() === 'outbound' && hoursSince(lastContactedAt) < 12) {\n console.log('SKIP: Double tap -', Math.round(hoursSince(lastContactedAt)), 'hrs ago');\n return [{ json: { action: 'SKIP', reason: `Double tap (${Math.round(hoursSince(lastContactedAt))}hrs)`, lead_id: leadId, phone, skipType: 'double_tap' } }];\n}\n\n// ========== 3. VA ACTIVE CHECK (<48hrs) ==========\nif (vaLastMsgAt && hoursSince(vaLastMsgAt) < 48) {\n console.log('SKIP: VA active -', Math.round(hoursSince(vaLastMsgAt)), 'hrs ago');\n return [{ json: { action: 'SKIP', reason: `VA active (${Math.round(hoursSince(vaLastMsgAt))}hrs)`, lead_id: leadId, phone, skipType: 'va_active' } }];\n}\n\n// ========== 4. STATUS LABEL EXCLUSIONS ==========\nconst EXCLUDED_STATUSES = ['dnc', 'human hand', 'hostile', 'paused'];\nfor (const excluded of EXCLUDED_STATUSES) {\n if (statusLabel.includes(excluded)) {\n console.log('SKIP: Excluded status -', statusLabel);\n return [{ json: { action: 'SKIP', reason: `Status: ${closeData.status_label}`, lead_id: leadId, phone, skipType: 'excluded_status' } }];\n }\n}\n\n// ========== 5. PIPELINE STAGE EXCLUSIONS ==========\nconst EXCLUDED_STAGES = [\n 'dnc', 'hot', 'warm', 'cold', 'human hand', 'hostile', 'paused',\n 'appointment', 'callback', 'wrong contact', 'creative finance',\n 'under contract', 'hostile - paused'\n];\nfor (const excluded of EXCLUDED_STAGES) {\n if (pipelineStage.includes(excluded)) {\n console.log('SKIP: Excluded stage -', pipelineStage);\n return [{ json: { action: 'SKIP', reason: `Stage: ${pipelineStage}`, lead_id: leadId, phone, skipType: 'excluded_stage' } }];\n }\n}\n\n// ========== 6. DNC CHECK (Convex) ==========\ntry {\n const dncResp = await this.helpers.httpRequest({\n method: 'GET',\n url: `${CONVEX_URL}/api/dnc-check?phone=${encodeURIComponent(phone)}`,\n json: true\n });\n if (dncResp && dncResp.isDnc === true) {\n return [{ json: { action: 'SKIP', reason: 'DNC (Convex)', lead_id: leadId, phone, skipType: 'dnc' } }];\n }\n} catch (err) {\n console.log('DNC check error:', err.message);\n}\n\n// ========== 7. TCPA CHECK ==========\ntry {\n const cleanPhone = phone.replace(/\\D/g, '');\n const areaCode = cleanPhone.length >= 10 ? cleanPhone.substring(cleanPhone.length - 10, cleanPhone.length - 7) : '216';\n const TIMEZONE_MAP = { '216': 'America/New_York', '440': 'America/New_York', '330': 'America/New_York' };\n const tz = TIMEZONE_MAP[areaCode] || 'America/New_York';\n const now = new Date();\n const opts = { timeZone: tz, hour: 'numeric', hour12: false, weekday: 'short' };\n const localTimeStr = now.toLocaleString('en-US', opts);\n const parts = localTimeStr.split(' ');\n const weekday = parts[0];\n const hour = parseInt(parts[1], 10);\n \n if (weekday === 'Sun') {\n return [{ json: { action: 'SKIP', reason: 'Sunday', lead_id: leadId, phone, skipType: 'tcpa_sunday' } }];\n }\n if (hour < 9 || hour >= 20) {\n return [{ json: { action: 'SKIP', reason: 'Outside hours', lead_id: leadId, phone, skipType: 'tcpa_hours' } }];\n }\n} catch (err) {\n console.log('TCPA check error:', err.message);\n}\n\n// ========== 8. RECENT REPLY CHECK ==========\ntry {\n const resp = await this.helpers.httpRequest({\n method: 'GET',\n url: `${CONVEX_URL}/api/last-inbound?lead_id=${encodeURIComponent(leadId)}`,\n json: true\n });\n if (resp && resp.timestamp) {\n const hrs = (Date.now() - new Date(resp.timestamp).getTime()) / 3600000;\n if (hrs < 24) {\n return [{ json: { action: 'SKIP', reason: 'Recent reply', lead_id: leadId, phone, skipType: 'recent_reply' } }];\n }\n }\n} catch (err) {}\n\n// ========== 9. HOSTILE CHECK (Convex) ==========\ntry {\n const resp = await this.helpers.httpRequest({\n method: 'GET',\n url: `${CONVEX_URL}/api/hostile-check?lead_id=${encodeURIComponent(leadId)}`,\n json: true\n });\n if (resp && resp.inCoolingPeriod) {\n return [{ json: { action: 'SKIP', reason: 'Hostile cooling', lead_id: leadId, phone, skipType: 'hostile' } }];\n }\n} catch (err) {}\n\nconsole.log('All safety checks passed!');\n\n// ========== GET ASSIGNED PHONE ==========\nlet assignedPhone = null;\nif (custom['Assigned Phone']) {\n assignedPhone = String(custom['Assigned Phone']);\n if (!assignedPhone.startsWith('+')) assignedPhone = '+' + assignedPhone;\n}\nif (!assignedPhone) {\n try {\n const resp = await this.helpers.httpRequest({ method: 'GET', url: `${CONVEX_URL}/api/phone-select`, json: true });\n if (resp && resp.phone) assignedPhone = resp.phone;\n } catch (e) {}\n}\nif (!assignedPhone) assignedPhone = '+15555550100';\n\n// ========== BUILD MESSAGE ==========\nconst streetName = address.replace(/^\\d+\\s+/, '').split(',')[0].trim();\nlet listingType;\nif (listingTypeRaw === 'HIGH_ARV' || listingTypeRaw === 'High_ARV' || listingTypeRaw === 'ARV') {\n listingType = 'High_ARV';\n} else if (listingTypeRaw && listingTypeRaw.indexOf('Expired') === 0) {\n listingType = listingTypeRaw; // Preserve Expired_Hot, Expired_Warm, Expired_Cold\n} else {\n listingType = 'BRRRR';\n}\n\nlet templateBody = null, templateId = null, templateVariant = null;\nconst FALLBACK = { 1: 'Hey, following up on {street_name}', 2: '{street_name} - still interested?', 3: 'Quick question about {street_name}', 4: 'Hey, {street_name}?', 5: 'Checking in on {street_name}', 6: 'Final note on {street_name}', 7: 'Market update for {street_name}', 8: 'Last message about {street_name}' };\n\ntry {\n const apiUrl = `${CONVEX_URL}/api/template-select-thompson?position=${sequencePosition}&listing_type=${encodeURIComponent(listingType)}`;\n const parsed = await this.helpers.httpRequest({ method: 'GET', url: apiUrl, json: true });\n if (parsed && parsed.selected && parsed.selected.body) {\n templateBody = parsed.selected.body;\n templateId = parsed.selected._id;\n templateVariant = parsed.selected.variant_name || 'A';\n }\n} catch (e) {\n console.log('Template API error:', e.message);\n}\n\nif (!templateBody) {\n templateBody = FALLBACK[sequencePosition] || FALLBACK[1];\n templateId = 'fallback_' + sequencePosition;\n templateVariant = 'fallback';\n}\n\nconst messageToSend = templateBody\n .replace(/\\{street_name\\}/gi, streetName)\n .replace(/\\{address\\}/gi, address)\n .replace(/\\{city\\}/gi, city)\n .replace(/\\{first_name\\}/gi, firstName);\n\nconsole.log('Message ready:', messageToSend.substring(0, 50));\n\n// Build queue data WITH _id from original Convex data\nconst queueData = {\n _id: originalQueue?._id,\n lead_id: leadId,\n phone: phone,\n first_name: firstName,\n address: address,\n city: city,\n zip: zip,\n sequence_position: sequencePosition,\n listing_type: listingType,\n tier: tier\n};\n\nreturn [{\n json: {\n action: 'SEND',\n leadId: leadId,\n contactId: contactId,\n assignedPhone: assignedPhone,\n phone: phone,\n firstName: firstName,\n address: address,\n city: city,\n zip: zip,\n streetName: streetName,\n sequencePosition: sequencePosition,\n listingType: listingType,\n tier: tier,\n messageToSend: messageToSend,\n templateId: templateId,\n templateVariant: templateVariant,\n templateBody: templateBody,\n _queue: queueData,\n _closeData: closeData,\n _debug: { version: '9.3', statusLabel, pipelineStage, lastMsgDirection, hasQueueId: !!originalQueue?._id }\n }\n}];"
},
"id": "build-message",
"name": "Build Message",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1296,
10384
]
},
{
"parameters": {
"maxItems": 300
},
"type": "n8n-nodes-base.limit",
"typeVersion": 1,
"position": [
832,
10592
],
"id": "ea41dc0d-b1bf-4d9c-a59e-4928f24d3935",
"name": "Limit"
},
{
"parameters": {
"content": "## Seller Follow-Up Engine\n\n**8-Touch SMS Sequence**\n\n### Sequence Timing (from DEVELOPER-README):\n| Pos | Name | Day | Phase |\n|-----|------|-----|-------|\n| 1 | Initial Outreach | Day 0 | Blitz |\n| 2 | Follow-up 1 | Day 0.5 (12hrs) | Blitz |\n| 3 | Follow-up 2 | Day 1 | Blitz |\n| 4 | Follow-up 3 | Day 4 | Extended |\n| 5 | Mid-Sequence 1 | Day 7 | Extended |\n| 6 | Mid-Sequence 2 | Day 14 | Extended |\n| 7 | Re-engagement 1 | Day 30 | Long-term |\n| 8 | Final Push | Day 60 | Long-term |\n\n### Key Endpoints:\n- Convex: `https://YOUR_CONVEX_DEPLOYMENT.convex.site`\n- Templates: `/api/templates?position={N}&listing_type={type}`\n- Queue: `/api/follow-up-queue-due`\n\n### Notes:\n- Code_Violation \u2192 mapped to BRRRR templates\n- Templates use {firstName} and {address} placeholders",
"height": 864
},
"id": "sticky-note-1",
"name": "Workflow Info",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-688,
10272
]
},
{
"parameters": {
"content": "## Seller Follow-Up Engine (Enhanced)\n\n**Purpose:** Automated follow-up SMS sequence for seller leads (Text 2-8). Runs on schedule to send timed follow-ups to leads who haven't responded.\n\n**Trigger:** Schedule Trigger (every X minutes)\n\n**Key Features:**\n- Fetches due follow-ups from Convex queue\n- Validates lead still exists in Close CRM\n- Detects stalled conversations\n- 1-second rate limiting between SMS\n- Phase transition tracking (Blitz \u2192 Extended)",
"height": 496
},
"id": "sticky-overview-new",
"name": "\ud83d\udccb Follow-Up Engine Overview",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-368,
10464
]
},
{
"parameters": {
"content": "## Data Flow\n\n1\ufe0f\u20e3 **Initialize Config** \u2192 Set up run parameters\n2\ufe0f\u20e3 **Should Run?** \u2192 Check if within operating hours\n3\ufe0f\u20e3 **Fetch Due from Convex** \u2192 Get leads needing follow-up\n4\ufe0f\u20e3 **Get Close Lead** \u2192 Verify lead exists in CRM\n5\ufe0f\u20e3 **Build Message** \u2192 Create personalized SMS\n6\ufe0f\u20e3 **Send SMS via Close** \u2192 Deliver follow-up text\n7\ufe0f\u20e3 **Calculate Next** \u2192 Determine next follow-up time\n8\ufe0f\u20e3 **Update Lead** \u2192 Update status in Close CRM\n9\ufe0f\u20e3 **Update Convex Queue** \u2192 Schedule next follow-up\n\n**Parallel:** Detect stalled conversations",
"height": 464
},
"id": "sticky-flow-new",
"name": "\ud83d\udd04 Data Flow",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
-352,
9904
]
},
{
"parameters": {
"content": "## Integrations\n\n**Queue Management:**\n- Convex (follow-up queue & scheduling)\n\n**CRM:**\n- Close CRM (lead data & SMS sending)\n\n**Notifications:**\n- Slack (success reports)\n\n**Rate Limiting:**\n- 1 SMS per second to prevent throttling\n\n**Cleanup:**\n- Auto-removes stale leads from queue",
"height": 496
},
"id": "sticky-integrations-new",
"name": "\ud83d\udd0c Integrations",
"type": "n8n-nodes-base.stickyNote",
"typeVersion": 1,
"position": [
16,
10048
]
},
{
"parameters": {
"method": "POST",
"url": "https://YOUR_CONVEX_DEPLOYMENT.convex.site/api/follow-up-queue-cancel",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={\n \"lead_id\": \"{{ $('Build Message').first().json.leadId }}\",\n \"reason\": \"sms_send_failed\"\n}",
"options": {
"timeout": 10000
}
},
"id": "delete-failed-from-queue",
"name": "Delete Failed Lead from Queue",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2640,
10608
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": true,
"typeValidation": "strict"
},
"conditions": [
{
"id": "check-not-skip",
"operator": {
"type": "string",
"operation": "notEquals"
},
"leftValue": "={{ $json.action }}",
"rightValue": "SKIP"
}
],
"combinator": "and"
},
"options": {}
},
"id": "check-should-send",
"name": "Should Send SMS?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1456,
10368
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"method": "POST",
"url": "https://YOUR_CONVEX_DEPLOYMENT.convex.site/api/queue-skip",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ lead_id: $json.lead_id, phone: $json.phone, skip_reason: $json.reason, skip_type: $json.skipType }) }}",
"options": {
"timeout": 30000
}
},
"id": "handle-skip",
"name": "Handle Skip (Update Queue)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2048,
10784
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"name": "leadId",
"value": "={{ $json.leadId }}",
"type": "string"
},
{
"name": "phone",
"value": "={{ $json.phone }}",
"type": "string"
},
{
"name": "templateId",
"value": "={{ $json.templateId }}",
"type": "string"
},
{
"name": "templateVariant",
"value": "={{ $json.templateVariant }}",
"type": "string"
},
{
"name": "messageToSend",
"value": "={{ $json.messageToSend }}",
"type": "string"
},
{
"name": "sequencePosition",
"value": "={{ $json.sequencePosition }}",
"type": "number"
},
{
"name": "assignedPhone",
"value": "={{ $json.assignedPhone.phone || $json.assignedPhone }}",
"type": "string"
},
{
"name": "contactId",
"value": "={{ $json.contactId }}",
"type": "string"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "store-message-data",
"name": "Store Message Data",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
1664,
10352
]
},
{
"parameters": {
"method": "POST",
"url": "https://YOUR_CONVEX_DEPLOYMENT.convex.site/api/phone-update",
"sendBody": true,
"specifyBody": "json",
"jsonBody": "={{ JSON.stringify({ phone: $('Store Message Data').item.json.assignedPhone, event_type: 'send' }) }}",
"options": {
"response": {
"response": {
"neverError": true
}
},
"timeout": 15000
}
},
"id": "update-phone-stats",
"name": "Update Phone Stats",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 4.2,
"position": [
2176,
9968
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"conditions": {
"options": {
"version": 2,
"leftValue": "",
"caseSensitive": false,
"typeValidation": "loose"
},
"conditions": [
{
"id": "not-dnc-status",
"leftValue": "={{ ($json.status_label || '').toLowerCase() }}",
"rightValue": "dnc",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-human-hand-status",
"leftValue": "={{ ($json.status_label || '').toLowerCase() }}",
"rightValue": "human hand",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-hostile-status",
"leftValue": "={{ ($json.status_label || '').toLowerCase() }}",
"rightValue": "hostile",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-paused-status",
"leftValue": "={{ ($json.status_label || '').toLowerCase() }}",
"rightValue": "paused",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-dnc-pipeline",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "dnc",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-hot-pipeline",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "hot",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-warm-pipeline",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "warm",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-cold-pipeline",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "cold",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-human-hand-pipeline",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "human hand",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-hostile-pipeline",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "hostile",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-wrong-contact",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "wrong contact",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-creative-finance",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "creative finance",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-under-contract",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "under contract",
"operator": {
"type": "string",
"operation": "notContains"
}
},
{
"id": "not-appointment",
"leftValue": "={{ String(($json.custom || {}).cf_POWtXhMQwwrBsrlkocDvtA5Fs57DvdKjwomTTgTP5vW || ($json.custom || {})['Pipeline Stage'] || '').toLowerCase() }}",
"rightValue": "appointment",
"operator": {
"type": "string",
"operation": "notContains"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "check-lead-status",
"name": "Check Lead Status",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2224,
10544
],
"onError": "continueErrorOutput"
},
{
"parameters": {
"jsCode": "// SKIP EXCLUDED STATUS v7.4 - Now calls queue-skip API to cancel\nconst CONVEX_URL = 'https://YOUR_CONVEX_DEPLOYMENT.convex.site';\n\nconst rawInput = $input.item.json;\n\nif (!rawInput || typeof rawInput !== 'object' || Array.isArray(rawInput)) {\n console.log('Invalid input - not an object');
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
Seller Follow-Up Engine (Enhanced). Uses httpRequest, slack. Scheduled trigger; 44 nodes.
Source: https://github.com/rafiulislam4246/real-estate-acquisition-workflows/blob/main/workflows/03-seller-followup-engine.json — 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.
debug. Uses httpRequest, slack, redis, mailgun. Scheduled trigger; 60 nodes.
This workflow is an automated employee time tracking and reporting system that monitors weekly work hours via TMetric, then delivers personalized summaries directly to each team member on Slack. It co
Import Productboard Notes Companies And Features Into Snowflake. Uses stickyNote, httpRequest, splitOut, snowflake. Scheduled trigger; 35 nodes.
Import Productboard Notes, Companies and Features into Snowflake. Uses stickyNote, httpRequest, splitOut, snowflake. Scheduled trigger; 35 nodes.
This workflow imports Productboard data into Snowflake, automating data extraction, mapping, and updates for features, companies, and notes. It supports scheduled weekly updates, data cleansing, and S