{
  "name": "APIVex - Property address enrichment",
  "nodes": [
    {
      "id": "instructions",
      "name": "Setup",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        0,
        -280
      ],
      "parameters": {
        "width": 780,
        "height": 240,
        "content": "## Property address enrichment\n1. Execute the workflow and upload a CSV (address column) or JSON array in the form. No fixed row cap; your plan quota and n8n resources apply.\n2. Create an **HTTP Header Auth** credential: name `x-apivex-key`, value your APIVex key. Select it in **Look up property**.\n3. Inspect **Review results** and download the file from **Export results CSV**; `returned` means a property was returned, not that its address was verified.\nRequests consume quota. This starter makes one request per unique address, with no automatic retries. Errors remain in output; fix them before rerunning. No schedule is enabled.\nGuide: https://apivex.com/workflows/property-address-enrichment"
      }
    },
    {
      "id": "trigger",
      "name": "Upload address file",
      "type": "n8n-nodes-base.formTrigger",
      "typeVersion": 2.6,
      "position": [
        0,
        40
      ],
      "webhookId": "apivex-property-upload",
      "parameters": {
        "authentication": "n8nUserAuth",
        "requireExecuteAccess": true,
        "formTitle": "Enrich property addresses",
        "formDescription": "Upload a CSV with an address column or a JSON array of addresses. One API request per unique address. Results are available in the n8n execution.",
        "formFields": {
          "values": [
            {
              "fieldLabel": "Address file",
              "fieldName": "address_file",
              "fieldType": "file",
              "multipleFiles": false,
              "acceptFileTypes": ".csv,.json",
              "requiredField": true
            }
          ]
        },
        "responseMode": "onReceived",
        "options": {
          "includeUserInOutput": false,
          "buttonLabel": "Enrich addresses"
        }
      }
    },
    {
      "id": "addresses",
      "name": "Extract addresses",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        220,
        40
      ],
      "parameters": {
        "jsCode": "// Runs in an n8n Code node. Read the uploaded binary through n8n's storage helper.\nconst upload = $input.first();\nconst keys = Object.keys(upload.binary || {});\nif (keys.length !== 1) throw new Error('Upload one CSV or JSON file.');\nconst file = upload.binary[keys[0]];\nconst text = (await this.helpers.getBinaryDataBuffer(0, keys[0])).toString('utf8').replace(/^\\uFEFF/, '');\nlet rows;\nif (/\\.json$/i.test(file.fileName || '')) {\n  const parsed = JSON.parse(text);\n  rows = Array.isArray(parsed) ? parsed : parsed.addresses;\n  if (!Array.isArray(rows)) throw new Error('JSON must contain an array of address strings or objects with an address field.');\n} else if (/\\.csv$/i.test(file.fileName || '')) {\n  // RFC-style CSV: quoted commas, escaped quotes, CRLF, and multiline fields.\n  const table = [];\n  let row = [], value = '', quoted = false, closed = false;\n  const finishCell = () => { row.push(value); value = ''; closed = false; };\n  const finishRow = () => { finishCell(); table.push(row); row = []; };\n  for (let i = 0; i < text.length; i++) {\n    const c = text[i];\n    if (quoted) {\n      if (c === '\"' && text[i + 1] === '\"') { value += '\"'; i++; }\n      else if (c === '\"') { quoted = false; closed = true; }\n      else value += c;\n    } else if (c === ',') finishCell();\n    else if (c === '\\n' || c === '\\r') { finishRow(); if (c === '\\r' && text[i + 1] === '\\n') i++; }\n    else if (c === '\"' && value === '' && !closed) quoted = true;\n    else if (closed || c === '\"') throw new Error('Malformed CSV quoting. Quote addresses containing commas.');\n    else value += c;\n  }\n  if (quoted) throw new Error('CSV contains an unclosed quoted field.');\n  if (value || row.length || closed) finishRow();\n  const header = table.shift() || [];\n  const columns = header.map(v => v.trim().toLowerCase());\n  const addressColumn = columns.indexOf('address');\n  if (addressColumn < 0 || columns.lastIndexOf('address') !== addressColumn) throw new Error('CSV needs exactly one address column.');\n  rows = table.filter(r => r.some(v => v.trim())).map((r, i) => {\n    if (r.length !== header.length) throw new Error('CSV row ' + (i + 2) + ' has the wrong number of columns.');\n    return { address: r[addressColumn] };\n  });\n} else throw new Error('Choose a .csv or .json file.');\n\n// Validate the whole upload before issuing requests. No fixed address-count cap.\nconst unique = new Map();\nrows.forEach((row, index) => {\n  const value = typeof row === 'string' ? row : row?.address;\n  if (typeof value !== 'string' || !value.trim()) throw new Error('Missing address at input row ' + (index + 1) + '.');\n  const address = value.trim();\n  if (!unique.has(address)) unique.set(address, { json: { address, input_row: index + 1 }, pairedItem: { item: 0 } });\n});\nif (!unique.size) throw new Error('The file contains no addresses.');\nreturn [...unique.values()];\n"
      }
    },
    {
      "id": "loop",
      "name": "One address at a time",
      "type": "n8n-nodes-base.splitInBatches",
      "typeVersion": 3,
      "position": [
        440,
        40
      ],
      "parameters": {
        "batchSize": 1,
        "options": {}
      }
    },
    {
      "id": "pause",
      "name": "Pause between requests",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1100,
        220
      ],
      "webhookId": "apivex-property-pause",
      "parameters": {
        "amount": 2,
        "unit": "seconds"
      }
    },
    {
      "id": "request",
      "name": "Look up property",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.2,
      "position": [
        660,
        220
      ],
      "onError": "continueRegularOutput",
      "parameters": {
        "url": "https://api.apivex.com/zillow/search_address",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendQuery": true,
        "queryParameters": {
          "parameters": [
            {
              "name": "address",
              "value": "={{ $json.address }}"
            }
          ]
        },
        "options": {
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "json"
            }
          },
          "timeout": 60000
        }
      }
    },
    {
      "id": "normalize",
      "name": "Review results",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        220
      ],
      "parameters": {
        "jsCode": "return $input.all().map((item, i) => {\n  const address = $('Extract addresses').itemMatching(i).json.address;\n  const response = item.json;\n  const code = Number(response.statusCode || 0);\n  const body = response.body;\n  const row = { input_row: $('Extract addresses').itemMatching(i).json.input_row, input_address: address, status: 'unexpected_response', http_status: code,\n    match_review: '', zpid: '', returned_address: '', price: '', currency: '', bedrooms: '',\n    bathrooms: '', living_area: '', home_type: '', home_status: '', zestimate: '', rent_zestimate: '' };\n  if (code < 200 || code >= 300) row.status = (code === 429 || Number(body?.statusCode) === 429) ? 'rate_limited' : code ? 'http_error' : 'network_error';\n  else if (body == null || (Array.isArray(body) && !body.length) ||\n      (typeof body === 'object' && !Object.keys(body).length)) row.status = 'no_result';\n  else if (Array.isArray(body)) row.status = body.length > 1 ? 'multiple_results_review' : 'unexpected_response';\n  else if (body.zpid && body.address && typeof body.address === 'object') {\n    row.status = 'returned'; row.match_review = 'required'; row.zpid = body.zpid;\n    row.returned_address = ['streetAddress','city','state','zipcode'].map(k => body.address[k]).filter(Boolean).join(', ');\n    const fields = {price:'price',currency:'currency',bedrooms:'bedrooms',bathrooms:'bathrooms',\n      living_area:'livingArea',home_type:'homeType',home_status:'homeStatus',zestimate:'zestimate',rent_zestimate:'rentZestimate'};\n    for (const [output, source] of Object.entries(fields)) row[output] = body[source] ?? '';\n  }\n  return {json:row, pairedItem:{item:i}};\n});"
      }
    },
    {
      "id": "export",
      "name": "Export results CSV",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        880,
        40
      ],
      "parameters": {
        "jsCode": "const items = $input.all();\nconst columns = Object.keys(items[0].json);\nconst cell = value => {\n  let text = String(value ?? '');\n  // Keep spreadsheet applications from treating source strings as formulas.\n  if (/^[\\s]*[=+@-]/.test(text)) text = \"'\" + text;\n  return '\"' + text.replace(/\"/g, '\"\"') + '\"';\n};\nconst csv = [columns.map(cell).join(','), ...items.map(item => columns.map(k => cell(item.json[k])).join(','))].join('\\r\\n');\nreturn [{json:{processed_addresses:items.length},binary:{data:await this.helpers.prepareBinaryData(Buffer.from(csv,'utf8'),'property-results.csv','text/csv')}}];\n"
      }
    }
  ],
  "connections": {
    "Upload address file": {
      "main": [
        [
          {
            "node": "Extract addresses",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Extract addresses": {
      "main": [
        [
          {
            "node": "One address at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "One address at a time": {
      "main": [
        [
          {
            "node": "Export results CSV",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Look up property",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Look up property": {
      "main": [
        [
          {
            "node": "Review results",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Review results": {
      "main": [
        [
          {
            "node": "Pause between requests",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pause between requests": {
      "main": [
        [
          {
            "node": "One address at a time",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1"
  },
  "active": false,
  "pinData": {},
  "tags": []
}
