AutomationFlowsAI & RAG › Send Monthly Rent and Sales Market Updates to Slack with Rentcast and Supabase

Send Monthly Rent and Sales Market Updates to Slack with Rentcast and Supabase

Byiamvaar @iamvaar on n8n.io

This workflow runs monthly, checks Supabase for up-to-date real estate market records for a configured ZIP code, and either reports existing month-over-month rent and sale trends or fetches fresh market stats from the Rentcast API, upserts them into Supabase, and posts a summary…

Cron / scheduled trigger★★★★☆ complexity28 nodesHTTP RequestSupabaseSlack
AI & RAG Trigger: Cron / scheduled Nodes: 28 Complexity: ★★★★☆ Added:

This workflow corresponds to n8n.io template #16914 — we link there as the canonical source.

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 →

Download .json
{
  "id": "KbbsNlzWnqBE3UMC",
  "meta": {
    "templateCredsSetupCompleted": true
  },
  "name": "Automated average House Rental & Sales info to slack",
  "tags": [],
  "nodes": [
    {
      "id": "e9c2c6e2-1cf7-436a-8998-f3d1a845a24a",
      "name": "Sticky Note",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        1968,
        -1168
      ],
      "parameters": {
        "width": 480,
        "height": 896,
        "content": "## Automated average House Rental & Sales info to Slack\n\n### How it works\n\n1. Triggers on a set schedule and configures a target zipcode for real estate analysis.\n2. Queries a Supabase database for existing rental and sales market records to see if updates are needed.\n3. Sends a Slack notification with the current status if the existing database records are already up-to-date.\n4. Fetches the latest market statistics from the Rentcast API if new data is required.\n5. Upserts the freshly fetched rental and sales market data into the Supabase database.\n6. Formats the results and sends a final Slack notification summarizing the newly updated market information.\n\n### Setup steps\n\n- [ ] Connect your Supabase account and ensure the required tables for market rentals and sales exist.\n- [ ] Configure the Rentcast API credentials in the HTTP Request node.\n- [ ] Connect your Slack workspace and specify the target channels in all Slack notification nodes.\n- [ ] Adjust the Schedule Trigger to run at your desired reporting frequency.\n\n### Customization\n\nYou can easily modify the Edit Fields node to track a different zipcode, or expand the JavaScript nodes to customize the Slack message formatting."
      },
      "typeVersion": 1
    },
    {
      "id": "2513b2cc-8642-411e-95b6-73f10a7eed32",
      "name": "Sticky Note1",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2528,
        -880
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 304,
        "content": "## Trigger and Configure Zipcode\n\nSets up the scheduled execution and initializes the target zipcode for the workflow."
      },
      "typeVersion": 1
    },
    {
      "id": "18ce6b0c-d846-4769-9248-c47e0b504fe7",
      "name": "Sticky Note2",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        2976,
        -976
      ],
      "parameters": {
        "color": 7,
        "width": 864,
        "height": 496,
        "content": "## Fetch and Evaluate Database Records\n\nRetrieves existing rental and sales records from Supabase, merges the results, and determines if an update is necessary."
      },
      "typeVersion": 1
    },
    {
      "id": "bd33b3c0-f953-4b72-9538-d5b714eb251d",
      "name": "Sticky Note3",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3872,
        -1168
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 496,
        "content": "## Existing Record Slack Notifications\n\nProcesses the existing database records and routes a corresponding status message to Slack."
      },
      "typeVersion": 1
    },
    {
      "id": "5f5ee003-d418-4329-bf7c-acbefd66417c",
      "name": "Sticky Note4",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        3872,
        -640
      ],
      "parameters": {
        "color": 7,
        "height": 384,
        "content": "## Fetch Rentcast Market Data\n\nSends a GET request to the Rentcast API to retrieve the latest real estate market statistics."
      },
      "typeVersion": 1
    },
    {
      "id": "9fa02f90-5b61-4cd2-b024-c7250b53577c",
      "name": "Sticky Note5",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4144,
        -640
      ],
      "parameters": {
        "color": 7,
        "width": 640,
        "height": 496,
        "content": "## Upsert Market Statistics\n\nSaves the newly fetched rental and sales market data into Supabase, then aggregates the upsert results."
      },
      "typeVersion": 1
    },
    {
      "id": "e48707f3-7640-4d03-b35f-bcdf6811d6d1",
      "name": "Sticky Note6",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        4816,
        -720
      ],
      "parameters": {
        "color": 7,
        "width": 416,
        "height": 320,
        "content": "## Format New Market Data\n\nUses custom JavaScript to format the aggregated market data and evaluates routing conditions for notifications."
      },
      "typeVersion": 1
    },
    {
      "id": "557a548d-9c65-4e52-99ae-36f7dfde1177",
      "name": "Sticky Note7",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        5264,
        -864
      ],
      "parameters": {
        "color": 7,
        "height": 576,
        "content": "## Market Update Slack Notifications\n\nDispatches Slack messages to the team summarizing the newly inserted real estate market updates."
      },
      "typeVersion": 1
    },
    {
      "id": "87d15805-25be-4531-a535-c8553807b03f",
      "name": "Monthly Trigger",
      "type": "n8n-nodes-base.scheduleTrigger",
      "position": [
        2576,
        -752
      ],
      "parameters": {
        "rule": {
          "interval": [
            {
              "field": "months",
              "triggerAtDayOfMonth": 5
            }
          ]
        }
      },
      "typeVersion": 1.3
    },
    {
      "id": "cb54ce84-5e21-4e12-a39d-38fddf10f5d7",
      "name": "Fetch Market Data",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        3920,
        -432
      ],
      "parameters": {
        "url": "https://api.rentcast.io/v1/markets",
        "options": {},
        "sendQuery": true,
        "sendHeaders": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "zipCode",
              "value": "={{ $('Set Zipcode Parameter').item.json.zipcode }}"
            },
            {
              "name": "dataType",
              "value": "All"
            },
            {
              "name": "historyRange",
              "value": "3"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Accept",
              "value": "application/json"
            },
            {
              "name": "X-Api-Key",
              "value": "86b21717132741519ed7b2b23049054b"
            }
          ]
        }
      },
      "typeVersion": 4.4
    },
    {
      "id": "300b1207-9eed-41a3-a645-e032ce667389",
      "name": "Set Zipcode Parameter",
      "type": "n8n-nodes-base.set",
      "position": [
        2800,
        -752
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "f0cf0b1a-df9b-4ba1-a6d3-4e1490250a8b",
              "name": "zipcode",
              "type": "number",
              "value": 78701
            }
          ]
        }
      },
      "typeVersion": 3.4
    },
    {
      "id": "c4dc3fbf-29f2-4c19-93f1-98283ffa6baa",
      "name": "Fetch Rentals from Supabase",
      "type": "n8n-nodes-base.supabase",
      "position": [
        3024,
        -848
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "zip_code",
              "keyValue": "={{ $json.zipcode }}",
              "condition": "eq"
            },
            {
              "keyName": "last_updated_date",
              "keyValue": "={{ $now.startOf('month').toISO() }}",
              "condition": "lt"
            }
          ]
        },
        "tableId": "market_rentals",
        "operation": "getAll"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "alwaysOutputData": true
    },
    {
      "id": "ef22eb80-768b-41b3-b9a6-be2200dac04e",
      "name": "Fetch Sales from Supabase",
      "type": "n8n-nodes-base.supabase",
      "position": [
        3024,
        -656
      ],
      "parameters": {
        "filters": {
          "conditions": [
            {
              "keyName": "zip_code",
              "keyValue": "={{ $json.zipcode }}",
              "condition": "eq"
            },
            {
              "keyName": "last_updated_date",
              "keyValue": "={{ $now.startOf('month').toISO() }}",
              "condition": "lt"
            }
          ]
        },
        "tableId": "market_sales",
        "operation": "getAll"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 1,
      "alwaysOutputData": true
    },
    {
      "id": "d0787b0b-820b-417f-adba-eff35122da19",
      "name": "Merge Supabase Results",
      "type": "n8n-nodes-base.merge",
      "position": [
        3248,
        -752
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "joinMode": "keepEverything",
        "fieldsToMatchString": "zip_code"
      },
      "typeVersion": 3.2,
      "alwaysOutputData": true
    },
    {
      "id": "e4ba9dad-ea73-4d6e-9ddf-aa6140df3d09",
      "name": "If Data Exists",
      "type": "n8n-nodes-base.if",
      "position": [
        3696,
        -752
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "6f64c529-1362-42b3-8df3-f2a040c698a0",
              "operator": {
                "type": "object",
                "operation": "notEmpty",
                "singleValue": true
              },
              "leftValue": "={{ $json.data[0] }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "d0b0200f-72e0-421d-984d-2db074d1f2bb",
      "name": "Aggregate Market Data",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        3472,
        -752
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "456d034d-32e7-4cb0-b5e1-a22e689abdb9",
      "name": "Merge Market Updates",
      "type": "n8n-nodes-base.merge",
      "position": [
        4416,
        -416
      ],
      "parameters": {
        "mode": "combine",
        "options": {},
        "joinMode": "keepEverything",
        "fieldsToMatchString": "zip_code"
      },
      "typeVersion": 3.2,
      "alwaysOutputData": true
    },
    {
      "id": "642e17c6-e792-4399-b6e7-a4ff5e7ad0ac",
      "name": "Upsert Rentals to Supabase",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4192,
        -512
      ],
      "parameters": {
        "url": "https://voffipplerpdeedrexvf.supabase.co/rest/v1/market_rentals",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "zip_code",
              "value": "={{ $json.zipCode }}"
            },
            {
              "name": "last_updated_date",
              "value": "={{ $json.saleData.lastUpdatedDate }}"
            },
            {
              "name": "average_rent",
              "value": "={{ $json.rentalData.averageRent }}"
            },
            {
              "name": "median_rent",
              "value": "={{ $json.rentalData.medianRent }}"
            },
            {
              "name": "min_rent",
              "value": "={{ $json.rentalData.minRent }}"
            },
            {
              "name": "max_rent",
              "value": "={{ $json.rentalData.maxRent }}"
            },
            {
              "name": "average_rent_per_sqft",
              "value": "={{ $json.rentalData.averageRentPerSquareFoot }}"
            },
            {
              "name": "median_rent_per_sqft",
              "value": "={{ $json.rentalData.medianRentPerSquareFoot }}"
            },
            {
              "name": "min_rent_per_sqft",
              "value": "={{ $json.rentalData.minRentPerSquareFoot }}"
            },
            {
              "name": "max_rent_per_sqft",
              "value": "={{ $json.rentalData.maxRentPerSquareFoot }}"
            },
            {
              "name": "average_sqft",
              "value": "={{ $json.rentalData.averageSquareFootage }}"
            },
            {
              "name": "median_sqft",
              "value": "={{ $json.rentalData.medianSquareFootage }}"
            },
            {
              "name": "min_sqft",
              "value": "={{ $json.rentalData.minSquareFootage }}"
            },
            {
              "name": "max_sqft",
              "value": "={{ $json.rentalData.maxSquareFootage }}"
            },
            {
              "name": "average_dom",
              "value": "={{ $json.rentalData.averageDaysOnMarket }}"
            },
            {
              "name": "median_dom",
              "value": "={{ $json.rentalData.medianDaysOnMarket }}"
            },
            {
              "name": "min_dom",
              "value": "={{ $json.rentalData.minDaysOnMarket }}"
            },
            {
              "name": "max_dom",
              "value": "={{ $json.rentalData.maxDaysOnMarket }}"
            },
            {
              "name": "new_listings",
              "value": "={{ $json.rentalData.newListings }}"
            },
            {
              "name": "total_listings",
              "value": "={{ $json.rentalData.totalListings }}"
            },
            {
              "name": "data_by_property_type_rental",
              "value": "={{ $json.rentalData.dataByPropertyType }}"
            },
            {
              "name": "data_by_bedrooms_rental",
              "value": "={{ $json.rentalData.dataByBedrooms }}"
            },
            {
              "name": "created_at",
              "value": "={{ $now }}"
            },
            {
              "name": "updated_at",
              "value": "={{ $now }}"
            },
            {
              "name": "history_rentals",
              "value": "={{ $json.rentalData.history }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Prefer",
              "value": "resolution=merge-duplicates"
            }
          ]
        },
        "nodeCredentialType": "supabaseApi"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "9c44e53c-53d2-40cd-93af-b9cfa6316b1d",
      "name": "Upsert Sales to Supabase",
      "type": "n8n-nodes-base.httpRequest",
      "position": [
        4192,
        -320
      ],
      "parameters": {
        "url": "https://voffipplerpdeedrexvf.supabase.co/rest/v1/market_sales",
        "method": "POST",
        "options": {},
        "sendBody": true,
        "sendHeaders": true,
        "authentication": "predefinedCredentialType",
        "bodyParameters": {
          "parameters": [
            {
              "name": "zip_code",
              "value": "={{ $json.zipCode }}"
            },
            {
              "name": "last_updated_date",
              "value": "={{ $json.saleData.lastUpdatedDate }}"
            },
            {
              "name": "average_price",
              "value": "={{ $json.saleData.averagePrice }}"
            },
            {
              "name": "median_price",
              "value": "={{ $json.saleData.medianPrice }}"
            },
            {
              "name": "min_price",
              "value": "={{ $json.saleData.minPrice }}"
            },
            {
              "name": "max_price",
              "value": "={{ $json.saleData.maxPrice }}"
            },
            {
              "name": "average_price_per_sqft",
              "value": "={{ $json.saleData.averagePricePerSquareFoot }}"
            },
            {
              "name": "median_price_per_sqft",
              "value": "={{ $json.saleData.medianPricePerSquareFoot }}"
            },
            {
              "name": "min_price_per_sqft",
              "value": "={{ $json.saleData.minPricePerSquareFoot }}"
            },
            {
              "name": "max_price_per_sqft",
              "value": "={{ $json.saleData.maxPricePerSquareFoot }}"
            },
            {
              "name": "average_sqft",
              "value": "={{ $json.saleData.averageSquareFootage }}"
            },
            {
              "name": "median_sqft",
              "value": "={{ $json.saleData.medianSquareFootage }}"
            },
            {
              "name": "min_sqft",
              "value": "={{ $json.saleData.minSquareFootage }}"
            },
            {
              "name": "max_sqft",
              "value": "={{ $json.saleData.maxSquareFootage }}"
            },
            {
              "name": "average_dom",
              "value": "={{ $json.saleData.averageDaysOnMarket }}"
            },
            {
              "name": "median_dom",
              "value": "={{ $json.saleData.medianDaysOnMarket }}"
            },
            {
              "name": "min_dom",
              "value": "={{ $json.saleData.minDaysOnMarket }}"
            },
            {
              "name": "max_dom",
              "value": "={{ $json.saleData.maxDaysOnMarket }}"
            },
            {
              "name": "new_listings",
              "value": "={{ $json.saleData.newListings }}"
            },
            {
              "name": "total_listings",
              "value": "={{ $json.saleData.totalListings }}"
            },
            {
              "name": "data_by_property_type_sales",
              "value": "={{ $json.saleData.dataByPropertyType }}"
            },
            {
              "name": "data_by_bedrooms_sales",
              "value": "={{ $json.saleData.dataByBedrooms }}"
            },
            {
              "name": "created_at",
              "value": "={{ $now }}"
            },
            {
              "name": "updated_at",
              "value": "={{ $now }}"
            },
            {
              "name": "history_sales",
              "value": "={{ $json.saleData.history }}"
            }
          ]
        },
        "headerParameters": {
          "parameters": [
            {
              "name": "Prefer",
              "value": "resolution=merge-duplicates"
            }
          ]
        },
        "nodeCredentialType": "supabaseApi"
      },
      "credentials": {
        "supabaseApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 4.1
    },
    {
      "id": "ab62c698-966c-4fa8-9827-fcc74221e4b0",
      "name": "Aggregate Upsert Status",
      "type": "n8n-nodes-base.aggregate",
      "position": [
        4640,
        -416
      ],
      "parameters": {
        "options": {},
        "aggregate": "aggregateAllItemData"
      },
      "typeVersion": 1
    },
    {
      "id": "52c992a9-da0f-402d-8a02-31a99ec2c350",
      "name": "Check Upsert Success",
      "type": "n8n-nodes-base.code",
      "position": [
        4864,
        -560
      ],
      "parameters": {
        "jsCode": "const data = $('Fetch Market Data').first().json;\n\nif (!data) {\n  throw new Error('No data found from HTTP Request node \u2014 check the connection and pinned data.');\n}\n\nconst id = data.id;\nconst zipCode = data.zipCode;\n\nconst saleHistory = data.saleData?.history || {};\nconst rentHistory = data.rentalData?.history || {};\n\nconst saleMonths = Object.keys(saleHistory).sort();\nconst rentMonths = Object.keys(rentHistory).sort();\n\nconst last3Sale = saleMonths.slice(-3);\nconst last3Rent = rentMonths.slice(-3);\n\nfunction compare(historyObj, months, field) {\n  const series = months.map(m => ({\n    month: m,\n    value: historyObj[m]?.[field] ?? null\n  }));\n\n  while (series.length < 3) series.unshift({ month: null, value: null });\n\n  const [twoAgo, oneAgo, current] = series;\n\n  const diff = (current?.value != null && oneAgo?.value != null)\n    ? current.value - oneAgo.value\n    : null;\n\n  const pct = (diff != null && oneAgo.value !== 0)\n    ? (diff / oneAgo.value) * 100\n    : null;\n\n  return {\n    months: series,\n    previousMonth: oneAgo,\n    currentMonth: current,\n    change: diff !== null ? Number(diff.toFixed(2)) : null,\n    percentChange: pct !== null ? Number(pct.toFixed(2)) : null,\n    direction: diff > 0 ? 'increase' : diff < 0 ? 'decrease' : diff === 0 ? 'no change' : 'insufficient data'\n  };\n}\n\n// Plain-English sentence for one metric (price or rent)\nfunction buildSentence(comparison, label, currency = true) {\n  const { previousMonth, currentMonth, change, percentChange, direction } = comparison;\n\n  if (!previousMonth?.month || !currentMonth?.month || change === null) {\n    return `Not enough historical data to compare ${label.toLowerCase()} month-over-month.`;\n  }\n\n  const fmt = (v) => currency\n    ? `$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`\n    : Math.abs(v).toLocaleString();\n\n  const curVal = fmt(currentMonth.value);\n  const prevVal = fmt(previousMonth.value);\n  const changeVal = fmt(change);\n  const pctVal = percentChange !== null ? `${Math.abs(percentChange)}%` : 'an unknown percentage';\n\n  if (direction === 'no change') {\n    return `${label} stayed flat at ${curVal} in ${currentMonth.month}, unchanged from ${previousMonth.month}.`;\n  }\n\n  return `${label} in ${currentMonth.month} is ${curVal}, ${direction === 'increase' ? 'up' : 'down'} ${changeVal} (${pctVal}) from ${prevVal} in ${previousMonth.month}.`;\n}\n\n// Slack mrkdwn version of the same metric \u2014 bold values, arrow + emoji for direction\nfunction buildSlackLine(comparison, label, currency = true) {\n  const { previousMonth, currentMonth, change, percentChange, direction } = comparison;\n\n  if (!previousMonth?.month || !currentMonth?.month || change === null) {\n    return `:warning: *${label}*: not enough historical data to compare month-over-month.`;\n  }\n\n  const fmt = (v) => currency\n    ? `$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`\n    : Math.abs(v).toLocaleString();\n\n  const curVal = fmt(currentMonth.value);\n  const prevVal = fmt(previousMonth.value);\n  const changeVal = fmt(change);\n  const pctVal = percentChange !== null ? `${Math.abs(percentChange)}%` : 'n/a';\n\n  const arrow = direction === 'increase' ? ':arrow_upper_right:' : direction === 'decrease' ? ':arrow_lower_right:' : ':heavy_minus_sign:';\n  const verb = direction === 'increase' ? 'up' : direction === 'decrease' ? 'down' : 'flat';\n\n  return `${arrow} *${label}*: *${curVal}* (${currentMonth.month}) \u2014 ${verb} ${changeVal} (*${pctVal}*) vs ${prevVal} in ${previousMonth.month}`;\n}\n\nconst saleComparison = compare(saleHistory, last3Sale, 'averagePrice');\nconst rentComparison = compare(rentHistory, last3Rent, 'averageRent');\n\nconst saleSentence = buildSentence(saleComparison, 'Average sale price', true);\nconst rentSentence = buildSentence(rentComparison, 'Average rent', true);\n\nconst saleSlackLine = buildSlackLine(saleComparison, 'Avg Sale Price', true);\nconst rentSlackLine = buildSlackLine(rentComparison, 'Avg Rent', true);\n\n// Flag: true only if BOTH sale and rent have real month-over-month comparisons\nconst hasSufficientData =\n  saleComparison.change !== null &&\n  rentComparison.change !== null &&\n  saleComparison.previousMonth?.month != null &&\n  saleComparison.currentMonth?.month != null &&\n  rentComparison.previousMonth?.month != null &&\n  rentComparison.currentMonth?.month != null;\n\nconst slackSummary = `*Market Update \u2014 ZIP ${zipCode}*\\n${saleSlackLine}\\n${rentSlackLine}`;\n\nreturn [{\n  json: {\n    id,\n    zipCode,\n    sale: saleComparison,\n    rent: rentComparison,\n    summary: `${saleSentence} ${rentSentence}`,\n    slackSummary,\n    hasSufficientData\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "ca8824ee-1e72-4b81-a9c1-05593b7311f5",
      "name": "Post Success to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        5312,
        -656
      ],
      "parameters": {
        "text": "={{ $json.slackSummary }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0B8VH1M5PX",
          "cachedResultName": "general"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "id": "c04056a2-1e85-4306-9bfe-95185b42ab5f",
      "name": "If Upsert Successful",
      "type": "n8n-nodes-base.if",
      "position": [
        5088,
        -560
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c86898ab-e3be-40dd-8c6a-3e4d7059c9b3",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.hasSufficientData }}",
              "rightValue": false
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "ae9ff542-993a-4b38-984b-4f5731791c5a",
      "name": "Post Error to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        5312,
        -464
      ],
      "parameters": {
        "text": "=There is an issue with the workflow, so please fix it ASAP.",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0B8VH1M5PX",
          "cachedResultName": "general"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "id": "beeb77d8-0f85-4780-a39d-245fac6746a9",
      "name": "Analyze Empty Data Response",
      "type": "n8n-nodes-base.code",
      "position": [
        3920,
        -944
      ],
      "parameters": {
        "jsCode": "// The Aggregate node bundles everything into an array named 'data'. \n// We need to target the first item inside that array.\nconst payload = $input.first().json; \nconst data = payload.data ? payload.data[0] : payload;\n\nif (!data) {\n  throw new Error('No data found from the previous node \u2014 check the connection and pinned data.');\n}\n\n// Map to the correct JSON keys \nconst id = data.id || null; \nconst zipCode = data.zip_code; \n\n// Target the correct history objects\nconst saleHistory = data.history_sales || {};\nconst rentHistory = data.history_rentals || {};\n\nconst saleMonths = Object.keys(saleHistory).sort();\nconst rentMonths = Object.keys(rentHistory).sort();\n\nconst last3Sale = saleMonths.slice(-3);\nconst last3Rent = rentMonths.slice(-3);\n\nfunction compare(historyObj, months, field) {\n  const series = months.map(m => ({\n    month: m,\n    value: historyObj[m]?.[field] ?? null\n  }));\n\n  while (series.length < 3) series.unshift({ month: null, value: null });\n\n  const [twoAgo, oneAgo, current] = series;\n\n  const diff = (current?.value != null && oneAgo?.value != null)\n    ? current.value - oneAgo.value\n    : null;\n\n  const pct = (diff != null && oneAgo.value !== 0)\n    ? (diff / oneAgo.value) * 100\n    : null;\n\n  return {\n    months: series,\n    previousMonth: oneAgo,\n    currentMonth: current,\n    change: diff !== null ? Number(diff.toFixed(2)) : null,\n    percentChange: pct !== null ? Number(pct.toFixed(2)) : null,\n    direction: diff > 0 ? 'increase' : diff < 0 ? 'decrease' : diff === 0 ? 'no change' : 'insufficient data'\n  };\n}\n\n// Plain-English sentence for one metric (price or rent)\nfunction buildSentence(comparison, label, currency = true) {\n  const { previousMonth, currentMonth, change, percentChange, direction } = comparison;\n\n  if (!previousMonth?.month || !currentMonth?.month || change === null) {\n    return `Not enough historical data to compare ${label.toLowerCase()} month-over-month.`;\n  }\n\n  const fmt = (v) => currency\n    ? `$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`\n    : Math.abs(v).toLocaleString();\n\n  const curVal = fmt(currentMonth.value);\n  const prevVal = fmt(previousMonth.value);\n  const changeVal = fmt(change);\n  const pctVal = percentChange !== null ? `${Math.abs(percentChange)}%` : 'an unknown percentage';\n\n  if (direction === 'no change') {\n    return `${label} stayed flat at ${curVal} in ${currentMonth.month}, unchanged from ${previousMonth.month}.`;\n  }\n\n  return `${label} in ${currentMonth.month} is ${curVal}, ${direction === 'increase' ? 'up' : 'down'} ${changeVal} (${pctVal}) from ${prevVal} in ${previousMonth.month}.`;\n}\n\n// Slack mrkdwn version of the same metric \u2014 bold values, arrow + emoji for direction\nfunction buildSlackLine(comparison, label, currency = true) {\n  const { previousMonth, currentMonth, change, percentChange, direction } = comparison;\n\n  if (!previousMonth?.month || !currentMonth?.month || change === null) {\n    return `:warning: *${label}*: not enough historical data to compare month-over-month.`;\n  }\n\n  const fmt = (v) => currency\n    ? `$${Math.abs(v).toLocaleString(undefined, { maximumFractionDigits: 0 })}`\n    : Math.abs(v).toLocaleString();\n\n  const curVal = fmt(currentMonth.value);\n  const prevVal = fmt(previousMonth.value);\n  const changeVal = fmt(change);\n  const pctVal = percentChange !== null ? `${Math.abs(percentChange)}%` : 'n/a';\n\n  const arrow = direction === 'increase' ? ':arrow_upper_right:' : direction === 'decrease' ? ':arrow_lower_right:' : ':heavy_minus_sign:';\n  const verb = direction === 'increase' ? 'up' : direction === 'decrease' ? 'down' : 'flat';\n\n  return `${arrow} *${label}*: *${curVal}* (${currentMonth.month}) \u2014 ${verb} ${changeVal} (*${pctVal}*) vs ${prevVal} in ${previousMonth.month}`;\n}\n\nconst saleComparison = compare(saleHistory, last3Sale, 'averagePrice');\nconst rentComparison = compare(rentHistory, last3Rent, 'averageRent');\n\nconst saleSentence = buildSentence(saleComparison, 'Average sale price', true);\nconst rentSentence = buildSentence(rentComparison, 'Average rent', true);\n\nconst saleSlackLine = buildSlackLine(saleComparison, 'Avg Sale Price', true);\nconst rentSlackLine = buildSlackLine(rentComparison, 'Avg Rent', true);\n\n// Flag: true only if BOTH sale and rent have real month-over-month comparisons\nconst hasSufficientData =\n  saleComparison.change !== null &&\n  rentComparison.change !== null &&\n  saleComparison.previousMonth?.month != null &&\n  saleComparison.currentMonth?.month != null &&\n  rentComparison.previousMonth?.month != null &&\n  rentComparison.currentMonth?.month != null;\n\nconst slackSummary = `*Market Update \u2014 ZIP ${zipCode}*\\n${saleSlackLine}\\n${rentSlackLine}`;\n\nreturn [{\n  json: {\n    id,\n    zipCode,\n    sale: saleComparison,\n    rent: rentComparison,\n    summary: `${saleSentence} ${rentSentence}`,\n    slackSummary,\n    hasSufficientData\n  }\n}];"
      },
      "typeVersion": 2
    },
    {
      "id": "cfa30766-8b38-4d57-aec7-59fcc09859bb",
      "name": "If Data Missing",
      "type": "n8n-nodes-base.if",
      "position": [
        4144,
        -944
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 3,
            "leftValue": "",
            "caseSensitive": true,
            "typeValidation": "strict"
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "cbd6cba0-4bd6-433f-a712-47757627a1a3",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.hasSufficientData }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2.3
    },
    {
      "id": "6f7b0f13-4c9d-4de2-a136-0278120ba4a8",
      "name": "Notify Missing Data to Slack",
      "type": "n8n-nodes-base.slack",
      "position": [
        4368,
        -1040
      ],
      "parameters": {
        "text": "={{ $json.slackSummary }}",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0B8VH1M5PX",
          "cachedResultName": "general"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    },
    {
      "id": "4ff88d7f-16ff-4354-9702-72ee4a111447",
      "name": "Notify Admin of Error",
      "type": "n8n-nodes-base.slack",
      "position": [
        4368,
        -848
      ],
      "parameters": {
        "text": "=There is an issue with the workflow, so please fix it ASAP.",
        "select": "channel",
        "channelId": {
          "__rl": true,
          "mode": "list",
          "value": "C0B8VH1M5PX",
          "cachedResultName": "general"
        },
        "otherOptions": {}
      },
      "credentials": {
        "slackApi": {
          "name": "<your credential>"
        }
      },
      "typeVersion": 2.5
    }
  ],
  "active": false,
  "settings": {
    "binaryMode": "separate",
    "availableInMCP": false,
    "executionOrder": "v1"
  },
  "versionId": "989d85e5-6d3f-4c20-af73-7e9a558b9b60",
  "nodeGroups": [],
  "connections": {
    "If Data Exists": {
      "main": [
        [
          {
            "node": "Analyze Empty Data Response",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Fetch Market Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Data Missing": {
      "main": [
        [
          {
            "node": "Notify Missing Data to Slack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Notify Admin of Error",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Monthly Trigger": {
      "main": [
        [
          {
            "node": "Set Zipcode Parameter",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Market Data": {
      "main": [
        [
          {
            "node": "Upsert Rentals to Supabase",
            "type": "main",
            "index": 0
          },
          {
            "node": "Upsert Sales to Supabase",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Check Upsert Success": {
      "main": [
        [
          {
            "node": "If Upsert Successful",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "If Upsert Successful": {
      "main": [
        [
          {
            "node": "Post Success to Slack",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Post Error to Slack",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Market Updates": {
      "main": [
        [
          {
            "node": "Aggregate Upsert Status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Market Data": {
      "main": [
        [
          {
            "node": "If Data Exists",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Set Zipcode Parameter": {
      "main": [
        [
          {
            "node": "Fetch Rentals from Supabase",
            "type": "main",
            "index": 0
          },
          {
            "node": "Fetch Sales from Supabase",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Merge Supabase Results": {
      "main": [
        [
          {
            "node": "Aggregate Market Data",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Aggregate Upsert Status": {
      "main": [
        [
          {
            "node": "Check Upsert Success",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Upsert Sales to Supabase": {
      "main": [
        [
          {
            "node": "Merge Market Updates",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Fetch Sales from Supabase": {
      "main": [
        [
          {
            "node": "Merge Supabase Results",
            "type": "main",
            "index": 1
          }
        ]
      ]
    },
    "Upsert Rentals to Supabase": {
      "main": [
        [
          {
            "node": "Merge Market Updates",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Analyze Empty Data Response": {
      "main": [
        [
          {
            "node": "If Data Missing",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch Rentals from Supabase": {
      "main": [
        [
          {
            "node": "Merge Supabase Results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

Credentials you'll need

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

Pro

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

About this workflow

This workflow runs monthly, checks Supabase for up-to-date real estate market records for a configured ZIP code, and either reports existing month-over-month rent and sale trends or fetches fresh market stats from the Rentcast API, upserts them into Supabase, and posts a summary…

Source: https://n8n.io/workflows/16914/ — original creator credit. Request a take-down →

More AI & RAG workflows → · Browse all categories →

Related workflows

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

AI & RAG

This workflow automatically fetches active deals from Zoho CRM, retrieves real-time market signals, calculates AI-enhanced forecast metrics, evaluates deal-market alignment, stores data in a database,

HTTP Request, Zoho Crm, Slack +2
AI & RAG

Legal, Procurement, and Compliance teams at mid-size companies. ESN and agencies selling AI-powered contract review as a service.

Form Trigger, HTTP Request, Form +4
AI & RAG

This workflow runs daily to pull job applicants from Google Sheets, score each candidate against role requirements using the Google Gemini API, log results back to Google Sheets, and notify a Slack ch

Google Sheets, HTTP Request, Slack +1
AI & RAG

This workflow runs daily, reads your blog RSS feed, uses Google Gemini to repurpose the latest posts into Twitter/X and LinkedIn drafts in your brand voice, then appends the drafts to Google Sheets an

RSS Feed Read, HTTP Request, Google Sheets +1
AI & RAG

A scheduled process aggregates content from eight distinct data sources and standardizes all inputs into a unified format. AI models perform sentiment scoring, detect conspiracy or misinformation sign

HTTP Request, OpenAI, Postgres +2