Real Estate Lead Nurture
Module 19 · business · intermediate
learner.json
In n8n, copy this file and paste it onto the canvas, or use Import from File.
{
"name": "19 - Real Estate Lead Nurture",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "re/lead",
"responseMode": "responseNode",
"options": {}
},
"id": "m19-lead-api",
"name": "New Lead API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
0
],
"webhookId": "course-19-lead"
},
{
"parameters": {
"pollTimes": {
"item": [
{
"mode": "everyMinute"
}
]
},
"simple": false,
"filters": {
"q": "from:(zillow.com OR realtor.com OR homes.com OR redfin.com) newer_than:1d",
"readStatus": "unread"
},
"options": {}
},
"id": "m19-gmail",
"name": "Portal Lead Emails",
"type": "n8n-nodes-base.gmailTrigger",
"typeVersion": 1.3,
"position": [
0,
180
]
},
{
"parameters": {
"rule": {
"interval": [
{
"field": "hours",
"hoursInterval": 1
}
]
}
},
"id": "m19-drip-schedule",
"name": "Drip Every Hour",
"type": "n8n-nodes-base.scheduleTrigger",
"typeVersion": 1.2,
"position": [
0,
420
]
},
{
"parameters": {
"httpMethod": "POST",
"path": "re/drip/run",
"responseMode": "responseNode",
"options": {}
},
"id": "m19-drip-api",
"name": "Drip Run API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
600
],
"webhookId": "course-19-drip"
},
{
"parameters": {
"httpMethod": "POST",
"path": "re/lead/stop",
"responseMode": "responseNode",
"options": {}
},
"id": "m19-stop-api",
"name": "Stop Lead API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
780
],
"webhookId": "course-19-stop"
},
{
"parameters": {
"path": "re/lead/status",
"responseMode": "responseNode",
"options": {}
},
"id": "m19-status-api",
"name": "Lead Status API",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [
0,
960
],
"webhookId": "course-19-status"
},
{
"parameters": {
"assignments": {
"assignments": [
{
"id": "s1",
"name": "brokerage",
"value": "Hill Country Realty",
"type": "string"
},
{
"id": "s2",
"name": "booking_link",
"value": "https://cal.com/hill-country-realty/showing",
"type": "string"
},
{
"id": "s3",
"name": "agents",
"value": "={{ [ { name: 'Maria Lopez', email: 'maria@hillcountry.example', phone: '(512) 555-0101' }, { name: 'James Carter', email: 'james@hillcountry.example', phone: '(512) 555-0102' } ] }}",
"type": "array"
},
{
"id": "s4",
"name": "sms_from",
"value": "+15125550100",
"type": "string"
},
{
"id": "s5",
"name": "sms_enabled",
"value": true,
"type": "boolean"
},
{
"id": "s6",
"name": "dedupe_days",
"value": 30,
"type": "number"
},
{
"id": "s7",
"name": "drip",
"value": "={{ [ { step: 'checkin', day: 1 }, { step: 'listings', day: 3 }, { step: 'last_chance', day: 7 } ] }}",
"type": "array"
},
{
"id": "s8",
"name": "listings",
"value": "={{ [\n { id: 'HC-1001', address: '214 Live Oak St', city: 'Austin', price: 465000, beds: 3, baths: 2, type: 'sale', status: 'active', listed_at: '2026-10-01', url: 'https://example.com/listings/HC-1001' },\n { id: 'HC-1002', address: '88 Barton Hills Dr', city: 'Austin', price: 520000, beds: 3, baths: 2.5, type: 'sale', status: 'active', listed_at: '2026-10-05', url: 'https://example.com/listings/HC-1002' },\n { id: 'HC-1003', address: '1910 Cedar Bend Ln', city: 'Austin', price: 430000, beds: 3, baths: 2, type: 'sale', status: 'active', listed_at: '2026-09-28', url: 'https://example.com/listings/HC-1003' },\n { id: 'HC-1004', address: '5 Riverside Ct', city: 'Austin', price: 899000, beds: 5, baths: 4, type: 'sale', status: 'active', listed_at: '2026-10-08', url: 'https://example.com/listings/HC-1004' },\n { id: 'HC-1005', address: '742 Maple Way', city: 'Round Rock', price: 410000, beds: 4, baths: 3, type: 'sale', status: 'active', listed_at: '2026-10-03', url: 'https://example.com/listings/HC-1005' },\n { id: 'HC-1006', address: '300 Lamar Blvd Unit 12', city: 'Austin', price: 2400, beds: 2, baths: 2, type: 'rent', status: 'active', listed_at: '2026-10-09', url: 'https://example.com/listings/HC-1006' },\n { id: 'HC-1007', address: '61 Pecan Grove Rd', city: 'Austin', price: 2150, beds: 2, baths: 1, type: 'rent', status: 'active', listed_at: '2026-10-02', url: 'https://example.com/listings/HC-1007' },\n { id: 'HC-1008', address: '17 Bluebonnet Dr', city: 'Austin', price: 489000, beds: 4, baths: 3, type: 'sale', status: 'pending', listed_at: '2026-09-20', url: 'https://example.com/listings/HC-1008' }\n] }}",
"type": "array"
}
]
},
"includeOtherFields": true,
"options": {}
},
"id": "m19-settings",
"name": "Settings (edit me)",
"type": "n8n-nodes-base.set",
"typeVersion": 3.4,
"position": [
240,
480
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r1",
"leftValue": "={{ $('New Lead API').isExecuted || $('Portal Lead Emails').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "new lead"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r2",
"leftValue": "={{ $('Drip Every Hour').isExecuted || $('Drip Run API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "drip"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r3",
"leftValue": "={{ $('Stop Lead API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "stop"
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "r4",
"leftValue": "={{ $('Lead Status API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"renameOutput": true,
"outputKey": "status"
}
]
},
"options": {}
},
"id": "m19-route",
"name": "Route",
"type": "n8n-nodes-base.switch",
"typeVersion": 3.2,
"position": [
460,
480
]
},
{
"parameters": {
"jsCode": "// Turn any inquiry into one clean lead: structured JSON, a pasted portal email, or the Gmail trigger.\nconst viaApi = $('New Lead API').isExecuted;\nconst src = viaApi ? $('New Lead API').first().json : $('Portal Lead Emails').first().json;\nconst b = viaApi ? src.body ?? {} : {};\n\n// Raw email text (API \"raw_email\" or Gmail trigger)\nconst from = viaApi ? String(b.from ?? '') : String(src.from?.value?.[0]?.address ?? src.from ?? '');\nconst subject = viaApi ? String(b.subject ?? '') : String(src.subject ?? '');\nconst raw = viaApi ? String(b.raw_email ?? '') : String(src.text ?? src.snippet ?? '');\nconst all = `${subject}\\n${raw}`;\nconst field = (label) => (raw.match(new RegExp(`^\\\\s*${label}\\\\s*[:\\\\-]\\\\s*(.+)$`, 'im'))?.[1] ?? '').trim();\n\nconst name = String(b.name ?? '').trim() || field('Name') || field('From');\nconst email = (String(b.email ?? '').trim() || field('Email') || (raw.match(/[\\w.+-]+@[\\w-]+\\.[\\w.-]+/)?.[0] ?? '')).toLowerCase();\nlet phone = (String(b.phone ?? '') || field('Phone') || (raw.match(/\\(?\\d{3}\\)?[\\s.-]\\d{3}[\\s.-]\\d{4}/)?.[0] ?? '')).replace(/[^\\d+]/g, '');\nif (/^\\d{10}$/.test(phone)) phone = '+1' + phone;\nif (/^1\\d{10}$/.test(phone)) phone = '+' + phone;\nconst message = String(b.message ?? '').trim() || field('Message') || field('Comments');\nconst property = String(b.property ?? '').trim() || field('Property') || field('Listing') || (all.match(/\\d{2,6} [A-Z][\\w ]+? (?:St|Street|Ave|Avenue|Rd|Road|Dr|Drive|Ln|Lane|Blvd|Ct|Way)\\b[^\\n]*/)?.[0] ?? '').trim();\nconst listingId = String(b.listing_id ?? '').trim() || field('Listing ID') || (all.match(/\\b(?:MLS|Listing)\\s*#?\\s*([A-Z0-9-]{4,})/i)?.[1] ?? '');\n\nconst text = `${message} ${subject} ${raw}`.toLowerCase();\nconst sourceHay = `${b.source ?? ''} ${from} ${subject}`.toLowerCase();\nconst source = ['zillow', 'realtor.com', 'homes.com', 'redfin', 'trulia'].find((s) => sourceHay.includes(s)) ?? (String(b.source ?? '').toLowerCase() || 'website');\nconst intent = /\\b(rent|lease|rental)\\b/.test(text) ? 'renter' : /(sell my|home value|what.?s my home worth|list my|selling)/.test(text) ? 'seller' : 'buyer';\nconst timeframe = (text.match(/\\b(asap|this week|this month|next month|30 days|60 days|90 days|3 months|6 months|this year|just looking|browsing)\\b/)?.[1]) ?? null;\n\nreturn [{\n json: {\n name, first_name: name.split(' ')[0] || 'there', email, phone, message, property, listing_id: listingId,\n source, intent, timeframe, pre_approved: /pre-?approved|preapproved/.test(text),\n valid: Boolean(email || phone),\n dry_run: viaApi && src.query?.dry_run === 'true',\n via_api: viaApi,\n received_at: DateTime.now().toISO(),\n },\n}];\n"
},
"id": "m19-parse",
"name": "Parse Lead",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
80
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "v1",
"leftValue": "={{ $json.valid }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m19-valid",
"name": "Has Contact?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
920,
80
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { error: 'need an email or phone: send {name, email, phone, message, property} or {from, subject, raw_email}' } }}",
"options": {
"responseCode": 400
}
},
"id": "m19-400",
"name": "Respond No Contact",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1140,
240
]
},
{
"parameters": {
"jsCode": "// Find the listing they asked about, then up to 3 similar ones (same type and city, price within ±20%).\nconst s = $('Settings (edit me)').first().json;\nconst lead = $input.first().json;\nconst L = s.listings;\nconst norm = (x) => String(x ?? '').toLowerCase().replace(/[^a-z0-9 ]/g, ' ').replace(/\\s+/g, ' ').trim();\n\nconst asked = L.find((l) => lead.listing_id && l.id.toLowerCase() === lead.listing_id.toLowerCase())\n ?? L.find((l) => lead.property && norm(lead.property).includes(norm(l.address).split(' ').slice(0, 3).join(' ')));\nconst wantType = lead.intent === 'renter' ? 'rent' : 'sale';\nconst ref = asked ?? { city: (L.find((l) => norm(lead.property).includes(norm(l.city)))?.city) ?? null, price: null, type: wantType };\n\nconst similar = L\n .filter((l) => l.status === 'active' && l.id !== asked?.id && l.type === (asked?.type ?? wantType))\n .filter((l) => !ref.city || l.city === ref.city)\n .filter((l) => !ref.price || Math.abs(l.price - ref.price) / ref.price <= 0.2)\n .sort((a, b) => (ref.price ? Math.abs(a.price - ref.price) - Math.abs(b.price - ref.price) : b.listed_at.localeCompare(a.listed_at)))\n .slice(0, 3);\n\nreturn [{ json: { ...lead, asked_listing: asked ?? null, matches: lead.intent === 'seller' ? [] : similar } }];\n"
},
"id": "m19-match",
"name": "Match Listings",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1140,
0
]
},
{
"parameters": {
"jsCode": "// Dedupe, assign an agent round-robin, and schedule the follow-up drip. State lives in workflow static data.\n// A dry run computes everything but saves nothing.\nconst s = $('Settings (edit me)').first().json;\nconst lead = $input.first().json;\nconst db = $getWorkflowStaticData('global');\ndb.leads = db.leads ?? {};\ndb.rr = db.rr ?? 0;\n\nconst key = lead.email || lead.phone;\nconst prev = db.leads[key];\nconst now = DateTime.fromISO(lead.received_at);\nconst isDuplicate = Boolean(prev && prev.status !== 'stopped' && now.diff(DateTime.fromISO(prev.created_at), 'days').days < s.dedupe_days);\n\nlet agent;\nif (isDuplicate) {\n agent = s.agents.find((a) => a.email === prev.agent_email) ?? s.agents[0];\n} else {\n agent = s.agents[db.rr % s.agents.length];\n}\n\nconst drip = isDuplicate ? prev.drip : s.drip.map((d) => ({ step: d.step, day: d.day, due_at: now.plus({ days: d.day }).toISO(), sent_at: null, last_error: null }));\nconst record = {\n key, name: lead.name, first_name: lead.first_name, email: lead.email, phone: lead.phone, intent: lead.intent,\n property: lead.asked_listing?.address ?? lead.property, match_ids: lead.matches.map((m) => m.id),\n agent_email: agent.email, agent_name: agent.name, status: 'active',\n created_at: isDuplicate ? prev.created_at : lead.received_at,\n inquiries: (prev?.inquiries ?? 0) + 1, drip,\n};\n\nif (!lead.dry_run) {\n if (!isDuplicate) db.rr += 1;\n db.leads[key] = record;\n}\n\nreturn [{ json: { ...lead, is_duplicate: isDuplicate, agent, drip } }];\n"
},
"id": "m19-register",
"name": "Register Lead",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1360,
0
]
},
{
"parameters": {
"jsCode": "// Instant replies: a short SMS and a fuller email, written from the assigned agent.\nconst s = $('Settings (edit me)').first().json;\nconst l = $input.first().json;\nconst money = (n) => '$' + Number(n).toLocaleString('en-US');\nconst home = (m) => `${m.address}, ${m.city}: ${money(m.price)}${m.type === 'rent' ? '/mo' : ''}, ${m.beds} bd / ${m.baths} ba`;\nconst agentFirst = l.agent.name.split(' ')[0];\nconst about = l.asked_listing?.address ?? (l.property || 'your home search');\nconst availability = l.asked_listing ? (l.asked_listing.status === 'active' ? \"It's still available\" : \"That one just went under contract, but I have similar homes\") : 'Happy to help';\n\nlet sms;\nlet subject;\nlet body;\nif (l.intent === 'seller') {\n sms = `Hi ${l.first_name}, it's ${agentFirst} with ${s.brokerage}. Thanks for reaching out about selling! I can put together a free home value report this week. When's a good time for a quick call? ${s.booking_link}`;\n subject = `Your home value report, ${l.first_name}`;\n body = `Hi ${l.first_name},\\n\\nThanks for reaching out about selling your home. I'll prepare a free, no-obligation market analysis based on recent sales nearby.\\n\\nPick a time that suits you for a 15-minute call: ${s.booking_link}\\n\\nBest,\\n${l.agent.name}\\n${s.brokerage} · ${l.agent.phone}`;\n} else if (l.is_duplicate) {\n sms = `Hi ${l.first_name}, ${agentFirst} again from ${s.brokerage}. Got your note about ${about}. Want to see it? Reply with a day that works or book here: ${s.booking_link}`;\n subject = `Re: ${about}`;\n body = `Hi ${l.first_name},\\n\\nThanks for reaching out again about ${about}. I'd love to show it to you. Book a time here: ${s.booking_link}\\n\\nBest,\\n${l.agent.name}\\n${s.brokerage} · ${l.agent.phone}`;\n} else {\n sms = `Hi ${l.first_name}, it's ${agentFirst} with ${s.brokerage}. Thanks for asking about ${about}! ${availability}. Want to see it this week? Reply with a day/time or book: ${s.booking_link}`;\n subject = `${about}: details + ${l.matches.length} similar home${l.matches.length === 1 ? '' : 's'}`;\n body = `Hi ${l.first_name},\\n\\nThanks for your interest in ${about}. ${availability}.\\n` +\n (l.matches.length ? `\\nYou might also like:\\n${l.matches.map((m) => `• ${home(m)}\\n ${m.url}`).join('\\n')}\\n` : '') +\n `\\nWant to see ${l.asked_listing ? 'it' : 'some homes'} in person? Book a showing here: ${s.booking_link}` +\n (l.pre_approved ? '' : `\\n\\nIf you're buying, getting pre-approved first makes your offer stronger. Happy to introduce a lender.`) +\n `\\n\\nBest,\\n${l.agent.name}\\n${s.brokerage} · ${l.agent.phone}`;\n}\nsms = (sms + ' Reply STOP to opt out.').slice(0, 320);\n\nreturn [{ json: { ...l, messages: { sms, email: { to: l.email, subject, body } } } }];\n"
},
"id": "m19-compose",
"name": "Compose Replies",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1580,
0
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "d1",
"leftValue": "={{ $json.dry_run }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m19-dry",
"name": "Dry Run?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1800,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ { dry_run: true, lead: { name: $json.name, email: $json.email, phone: $json.phone, source: $json.source, intent: $json.intent, timeframe: $json.timeframe, pre_approved: $json.pre_approved }, is_duplicate: $json.is_duplicate, agent: $json.agent, asked_listing: $json.asked_listing, matches: $json.matches, messages: $json.messages, drip: $json.drip } }}",
"options": {}
},
"id": "m19-respond-dry",
"name": "Respond Plan",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
2020,
-120
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "p1",
"leftValue": "={{ Boolean($json.phone) && $('Settings (edit me)').first().json.sms_enabled }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m19-hasphone",
"name": "Text Them?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2020,
80
]
},
{
"parameters": {
"resource": "sms",
"operation": "send",
"from": "={{ $('Settings (edit me)').first().json.sms_from }}",
"to": "={{ $json.phone }}",
"message": "={{ $json.messages.sms }}",
"options": {}
},
"id": "m19-sms",
"name": "Send SMS",
"type": "n8n-nodes-base.twilio",
"typeVersion": 1,
"position": [
2240,
0
],
"onError": "continueRegularOutput"
},
{
"parameters": {
"sendTo": "={{ $('Compose Replies').first().json.messages.email.to }}",
"subject": "={{ $('Compose Replies').first().json.messages.email.subject }}",
"emailType": "text",
"message": "={{ $('Compose Replies').first().json.messages.email.body }}",
"options": {
"appendAttribution": false,
"senderName": "={{ $('Compose Replies').first().json.agent.name }}"
}
},
"id": "m19-email",
"name": "Send Email",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
2460,
80
],
"webhookId": "course-19-gmail-send",
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// What the lead received. SMS and email both run with \"On Error: Continue\"; success requires an id.\nconst l = $('Compose Replies').first().json;\nconst smsRan = $('Send SMS').isExecuted;\nconst sms = smsRan ? $('Send SMS').first().json : null;\nconst mail = $('Send Email').first().json;\nconst err = (j) => (typeof j?.error === 'string' ? j.error : j?.error?.message ?? null);\nconst smsOk = Boolean(sms && !sms.error && sms.sid);\nconst mailOk = Boolean(mail && !mail.error && mail.id);\nreturn [{\n json: {\n lead: { name: l.name, email: l.email, phone: l.phone, source: l.source, intent: l.intent, timeframe: l.timeframe, pre_approved: l.pre_approved },\n is_duplicate: l.is_duplicate,\n agent: l.agent,\n asked_listing: l.asked_listing,\n matches: l.matches,\n messages: l.messages,\n drip: l.drip,\n sent: {\n sms: smsOk, sms_error: smsRan ? (smsOk ? null : err(sms) ?? 'no message sid returned') : 'skipped (no phone or SMS disabled)',\n email: mailOk, email_error: mailOk ? null : err(mail) ?? 'no message id returned',\n },\n },\n}];\n"
},
"id": "m19-report",
"name": "Lead Report",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
2680,
80
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "a1",
"leftValue": "={{ $('New Lead API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m19-viaapi",
"name": "From API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
2900,
0
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "m19-respond",
"name": "Respond Report",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
3120,
0
]
},
{
"parameters": {},
"id": "m19-alert",
"name": "Alert Agent (Exercise 1)",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
2900,
200
]
},
{
"parameters": {
"jsCode": "// Find drip messages that are due now, for leads still active. One item per message.\n// \"now\" can be injected via the API ({\"now\": \"...\"}) so the schedule is testable.\nconst s = $('Settings (edit me)').first().json;\nconst viaApi = $('Drip Run API').isExecuted;\nconst body = viaApi ? $('Drip Run API').first().json.body ?? {} : {};\nconst now = body.now ? DateTime.fromISO(body.now) : DateTime.now();\nconst db = $getWorkflowStaticData('global');\nconst leads = Object.values(db.leads ?? {});\nconst byId = Object.fromEntries(s.listings.map((l) => [l.id, l]));\nconst money = (n) => '$' + Number(n).toLocaleString('en-US');\n\nconst TEMPLATES = {\n checkin: (l) => ({\n subject: `Any questions about ${l.property || 'the homes I sent'}?`,\n body: `Hi ${l.first_name},\\n\\nJust checking in: do you have any questions about ${l.property || 'the homes I sent'}? I'm happy to set up a showing or send more photos and details.\\n\\nBook a time: ${s.booking_link}\\n\\n${l.agent_name}\\n${s.brokerage}`,\n }),\n listings: (l) => {\n const homes = l.match_ids.map((id) => byId[id]).filter(Boolean);\n return {\n subject: homes.length ? `${homes.length} homes you might like` : 'Homes that just came on the market',\n body: `Hi ${l.first_name},\\n\\n` + (homes.length\n ? `Based on what you were looking at, these could be a fit:\\n${homes.map((h) => `• ${h.address}, ${h.city}: ${money(h.price)}, ${h.beds} bd / ${h.baths} ba\\n ${h.url}`).join('\\n')}\\n\\n`\n : `New listings come up every week. Tell me your budget and must-haves and I'll send the best matches.\\n\\n`) +\n `Want to tour any of them? ${s.booking_link}\\n\\n${l.agent_name}\\n${s.brokerage}`,\n };\n },\n last_chance: (l) => ({\n subject: 'Should I keep you on my list?',\n body: `Hi ${l.first_name},\\n\\nI haven't heard back, so I don't want to fill your inbox. Are you still looking, or should I close your file for now? A one-word reply is perfect.\\n\\n${l.agent_name}\\n${s.brokerage}`,\n }),\n};\n\nconst due = [];\nfor (const l of leads) {\n if (l.status !== 'active' || !l.email) continue;\n for (const d of l.drip) {\n if (!d.sent_at && DateTime.fromISO(d.due_at) <= now) {\n due.push({ key: l.key, step: d.step, to: l.email, agent: l.agent_name, ...TEMPLATES[d.step](l), dry_run: body.dry_run === true });\n }\n }\n}\nreturn due.map((json) => ({ json }));\n"
},
"id": "m19-due",
"name": "Find Due Messages",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
560
],
"alwaysOutputData": true
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "n1",
"leftValue": "={{ Boolean($json.key) && !$json.dry_run }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m19-anydue",
"name": "Send Now?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
920,
560
]
},
{
"parameters": {
"sendTo": "={{ $json.to }}",
"subject": "={{ $json.subject }}",
"emailType": "text",
"message": "={{ $json.body }}",
"options": {
"appendAttribution": false,
"senderName": "={{ $json.agent }}"
}
},
"id": "m19-drip-email",
"name": "Send Drip Email",
"type": "n8n-nodes-base.gmail",
"typeVersion": 2.1,
"position": [
1140,
480
],
"webhookId": "course-19-gmail-drip",
"onError": "continueRegularOutput"
},
{
"parameters": {
"jsCode": "// Mark each drip email as sent (or record the error so the next run retries it).\nconst db = $getWorkflowStaticData('global');\nconst dueItems = $('Find Due Messages').all().map((i) => i.json).filter((d) => d.key); // \"Always Output Data\" adds an empty item when nothing is due\nconst sendRan = $('Send Drip Email').isExecuted;\nconst results = sendRan ? $('Send Drip Email').all().map((i) => i.json) : [];\n\nconst out = dueItems.map((d, i) => {\n const r = results[i];\n const ok = Boolean(r && !r.error && r.id);\n const error = d.dry_run ? null : ok ? null : (typeof r?.error === 'string' ? r.error : r?.error?.message) ?? 'no message id returned';\n if (!d.dry_run) {\n const step = db.leads[d.key]?.drip.find((x) => x.step === d.step);\n if (step) { if (ok) step.sent_at = DateTime.now().toISO(); else step.last_error = error; }\n }\n return { key: d.key, step: d.step, to: d.to, subject: d.subject, sent: ok, error, dry_run: d.dry_run };\n});\nreturn [{ json: { due: out.length, sent: out.filter((o) => o.sent).length, messages: out } }];\n"
},
"id": "m19-record",
"name": "Record Drip Results",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
1360,
560
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 2
},
"conditions": [
{
"id": "b1",
"leftValue": "={{ $('Drip Run API').isExecuted }}",
"rightValue": "",
"operator": {
"type": "boolean",
"operation": "true",
"singleValue": true
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "m19-dripapi",
"name": "Drip via API?",
"type": "n8n-nodes-base.if",
"typeVersion": 2.2,
"position": [
1580,
560
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {}
},
"id": "m19-respond-drip",
"name": "Respond Drip",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
1800,
520
]
},
{
"parameters": {
"jsCode": "// Stop the drip when a lead replies, books, or opts out.\nconst b = $('Stop Lead API').first().json.body ?? {};\nconst db = $getWorkflowStaticData('global');\ndb.leads = db.leads ?? {};\nlet phone = String(b.phone ?? '').replace(/[^\\d+]/g, '');\nif (/^\\d{10}$/.test(phone)) phone = '+1' + phone;\nconst key = String(b.email ?? '').trim().toLowerCase() || phone;\nconst lead = db.leads[key];\nif (!lead) return [{ json: { found: false, key } }];\nlead.status = 'stopped';\nlead.stop_reason = String(b.reason ?? 'replied');\nlead.stopped_at = DateTime.now().toISO();\nreturn [{ json: { found: true, key, status: lead.status, stop_reason: lead.stop_reason, pending_steps_cancelled: lead.drip.filter((d) => !d.sent_at).map((d) => d.step) } }];\n"
},
"id": "m19-stop",
"name": "Stop Drip",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
780
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseCode": "={{ $json.found ? 200 : 404 }}"
}
},
"id": "m19-respond-stop",
"name": "Respond Stop",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
780
]
},
{
"parameters": {
"jsCode": "// Read-only view of one lead's record (for checking the drip and debugging).\nconst q = $('Lead Status API').first().json.query ?? {};\nlet phone = String(q.phone ?? '').replace(/[^\\d+]/g, '');\nif (/^\\d{10}$/.test(phone)) phone = '+1' + phone;\nconst key = String(q.email ?? '').trim().toLowerCase() || phone;\nconst lead = ($getWorkflowStaticData('global').leads ?? {})[key];\nreturn [{ json: lead ? { found: true, ...lead } : { found: false, key } }];\n"
},
"id": "m19-status",
"name": "Lead Status",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
700,
960
]
},
{
"parameters": {
"respondWith": "json",
"responseBody": "={{ $json }}",
"options": {
"responseCode": "={{ $json.found ? 200 : 404 }}"
}
},
"id": "m19-respond-status",
"name": "Respond Status",
"type": "n8n-nodes-base.respondToWebhook",
"typeVersion": 1.1,
"position": [
920,
960
]
}
],
"connections": {
"New Lead API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Portal Lead Emails": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Drip Every Hour": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Drip Run API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Stop Lead API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Lead Status API": {
"main": [
[
{
"node": "Settings (edit me)",
"type": "main",
"index": 0
}
]
]
},
"Settings (edit me)": {
"main": [
[
{
"node": "Route",
"type": "main",
"index": 0
}
]
]
},
"Route": {
"main": [
[
{
"node": "Parse Lead",
"type": "main",
"index": 0
}
],
[
{
"node": "Find Due Messages",
"type": "main",
"index": 0
}
],
[
{
"node": "Stop Drip",
"type": "main",
"index": 0
}
],
[
{
"node": "Lead Status",
"type": "main",
"index": 0
}
]
]
},
"Parse Lead": {
"main": [
[
{
"node": "Has Contact?",
"type": "main",
"index": 0
}
]
]
},
"Has Contact?": {
"main": [
[
{
"node": "Match Listings",
"type": "main",
"index": 0
}
],
[
{
"node": "Respond No Contact",
"type": "main",
"index": 0
}
]
]
},
"Match Listings": {
"main": [
[
{
"node": "Register Lead",
"type": "main",
"index": 0
}
]
]
},
"Register Lead": {
"main": [
[
{
"node": "Compose Replies",
"type": "main",
"index": 0
}
]
]
},
"Compose Replies": {
"main": [
[
{
"node": "Dry Run?",
"type": "main",
"index": 0
}
]
]
},
"Dry Run?": {
"main": [
[
{
"node": "Respond Plan",
"type": "main",
"index": 0
}
],
[
{
"node": "Text Them?",
"type": "main",
"index": 0
}
]
]
},
"Text Them?": {
"main": [
[
{
"node": "Send SMS",
"type": "main",
"index": 0
}
],
[
{
"node": "Send Email",
"type": "main",
"index": 0
}
]
]
},
"Send SMS": {
"main": [
[
{
"node": "Send Email",
"type": "main",
"index": 0
}
]
]
},
"Send Email": {
"main": [
[
{
"node": "Lead Report",
"type": "main",
"index": 0
}
]
]
},
"Lead Report": {
"main": [
[
{
"node": "From API?",
"type": "main",
"index": 0
},
{
"node": "Alert Agent (Exercise 1)",
"type": "main",
"index": 0
}
]
]
},
"From API?": {
"main": [
[
{
"node": "Respond Report",
"type": "main",
"index": 0
}
]
]
},
"Find Due Messages": {
"main": [
[
{
"node": "Send Now?",
"type": "main",
"index": 0
}
]
]
},
"Send Now?": {
"main": [
[
{
"node": "Send Drip Email",
"type": "main",
"index": 0
}
],
[
{
"node": "Record Drip Results",
"type": "main",
"index": 0
}
]
]
},
"Send Drip Email": {
"main": [
[
{
"node": "Record Drip Results",
"type": "main",
"index": 0
}
]
]
},
"Record Drip Results": {
"main": [
[
{
"node": "Drip via API?",
"type": "main",
"index": 0
}
]
]
},
"Drip via API?": {
"main": [
[
{
"node": "Respond Drip",
"type": "main",
"index": 0
}
]
]
},
"Stop Drip": {
"main": [
[
{
"node": "Respond Stop",
"type": "main",
"index": 0
}
]
]
},
"Lead Status": {
"main": [
[
{
"node": "Respond Status",
"type": "main",
"index": 0
}
]
]
}
},
"pinData": {},
"settings": {
"executionOrder": "v1"
}
}