{
  "name": "Building permits → project review rows",
  "active": false,
  "nodes": [
    {
      "id": "manual",
      "name": "Start manually",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        300
      ],
      "parameters": {}
    },
    {
      "id": "settings",
      "name": "Edit small input",
      "type": "n8n-nodes-base.set",
      "typeVersion": 3.4,
      "position": [
        240,
        300
      ],
      "parameters": {
        "mode": "raw",
        "jsonOutput": "{\n  \"existingRunId\": \"\",\n  \"actorInput\": {\n    \"cities\": [\n      \"chicago\"\n    ],\n    \"permitTypeKeywords\": [],\n    \"onlyWithContractor\": false,\n    \"maxResultsPerCity\": 5,\n    \"maxRunSeconds\": 120,\n    \"includeRaw\": false\n  }\n}",
        "options": {}
      }
    },
    {
      "id": "validate",
      "name": "Validate settings",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        480,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const validateConfig = function validateConfig(value, actorId, kind) {\n  const object = v => v && typeof v === 'object' && !Array.isArray(v);\n  if (!object(value) || !object(value.actorInput)) throw Error('Configure one Actor input object.');\n  const input = value.actorInput;\n  const limit = kind === 'permits' ? input.maxResultsPerCity * input.cities?.length : input.maxResults;\n  if (!Number.isSafeInteger(limit) || limit < 1 || limit > 25) throw Error('This starter template supports 1–25 total rows. Keep a small explicit result cap.');\n  if (!Number.isSafeInteger(input.maxRunSeconds) || input.maxRunSeconds < 30 || input.maxRunSeconds > 120) throw Error('Keep maxRunSeconds between 30 and 120; this template launches with a 180-second platform timeout.');\n  if (kind === 'permits' && (!Array.isArray(input.cities) || !input.cities.length || new Set(input.cities).size !== input.cities.length || input.cities.some(x => !['chicago','san_francisco','new_york','austin','seattle','los_angeles','baton_rouge','cincinnati','mesa','marin_county'].includes(x)))) throw Error('Select explicit supported permit cities with no duplicates.');\n  const existingRunId = value.existingRunId ?? '';\n  if (typeof existingRunId !== 'string' || existingRunId && !/^[A-Za-z0-9]{17}$/.test(existingRunId)) throw Error('existingRunId must be blank or an exact 17-character Apify run ID.');\n  return { actorId, kind, actorInput: input, rowLimit: limit, existingRunId, startedAtMs: Date.now() };\n};\nreturn [{json:validateConfig($input.first().json,\"EvCD8QyPV2uMbPG6U\",\"permits\")}];"
      }
    },
    {
      "id": "new",
      "name": "Start a new run?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        720,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "new-condition",
              "leftValue": "={{ !$json.existingRunId }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "launch",
      "name": "Launch once - costs apply",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        960,
        200
      ],
      "parameters": {
        "method": "POST",
        "url": "https://api.apify.com/v2/acts/EvCD8QyPV2uMbPG6U/runs?timeout=180&memory=256&maxTotalChargeUsd=0.15",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "contentType": "raw",
        "rawContentType": "application/json",
        "body": "={{ JSON.stringify($('Validate settings').first().json.actorInput) }}",
        "options": {
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "autodetect",
              "outputPropertyName": "body"
            }
          },
          "timeout": 15000
        }
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "launch-receipt",
      "name": "Keep launch run ID",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        200
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const readResponse = function readResponse(response, statuses = [200]) {\n  if (!response || !statuses.includes(response.statusCode)) throw Error('HTTP request failed or returned an unexpected status. Inspect the node; do not automatically repeat a launch.');\n  const contentType = Object.entries(response.headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1];\n  if (typeof contentType !== 'string' || !/^application\\/json(?:\\s*;|$)/i.test(contentType)) throw Error('Expected JSON; redirects and HTML responses are not accepted.');\n  const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;\n  if (body == null || typeof body !== 'object') throw Error('Response has no JSON object or array.');\n  return body;\n};\nconst launchContext = function launchContext(response, config, read) {\n  try {\n    const run = read(response, [201]).data;\n    if (!run || run.actId !== config.actorId || !/^[A-Za-z0-9]{17}$/.test(run.id)) throw Error('Unrecognized launch response.');\n    return { runId: run.id, startedAtMs: config.startedAtMs };\n  } catch {\n    throw Error('No verified launch receipt. The Actor MAY have started. Check Apify Console before retrying; paste its run ID into existingRunId to resume. This template never retries POST automatically.');\n  }\n};\nreturn [{json:launchContext($input.first().json,$('Validate settings').first().json,readResponse)}];"
      }
    },
    {
      "id": "resume",
      "name": "Use existing run ID",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1200,
        440
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const c=$('Validate settings').first().json; return [{json:{runId:c.existingRunId,startedAtMs:c.startedAtMs}}];"
      }
    },
    {
      "id": "context",
      "name": "Run context - retain this ID",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        1440,
        300
      ],
      "parameters": {}
    },
    {
      "id": "status",
      "name": "Read run status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        1680,
        300
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/actor-runs/' + $('Run context - retain this ID').first().json.runId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "autodetect",
              "outputPropertyName": "body"
            }
          },
          "timeout": 15000
        }
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "inspect-status",
      "name": "Inspect run status",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1920,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const readResponse = function readResponse(response, statuses = [200]) {\n  if (!response || !statuses.includes(response.statusCode)) throw Error('HTTP request failed or returned an unexpected status. Inspect the node; do not automatically repeat a launch.');\n  const contentType = Object.entries(response.headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1];\n  if (typeof contentType !== 'string' || !/^application\\/json(?:\\s*;|$)/i.test(contentType)) throw Error('Expected JSON; redirects and HTML responses are not accepted.');\n  const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;\n  if (body == null || typeof body !== 'object') throw Error('Response has no JSON object or array.');\n  return body;\n};\nconst inspectRun = function inspectRun(response, config, context, iteration, read, now = Date.now()) {\n  const fail = reason => ({ route: 'failure', runId: context.runId, reason });\n  let run;\n  try { run = read(response).data; } catch { return fail('Could not read run status. Resume this same run ID after resolving the API issue.'); }\n  if (!run || run.id !== context.runId || run.actId !== config.actorId) return fail('Run identity does not match this template.');\n  if (run.status === 'SUCCEEDED') {\n    if (![run.defaultDatasetId, run.defaultKeyValueStoreId].every(v => typeof v === 'string' && /^[A-Za-z0-9]{17}$/.test(v))) return fail('Completed run is missing its own dataset or key-value store.');\n    return { route: 'success', runId: run.id, datasetId: run.defaultDatasetId, storeId: run.defaultKeyValueStoreId, status: run.status };\n  }\n  if (!['READY','RUNNING','TIMING-OUT','ABORTING'].includes(run.status)) return fail('Actor ended with status ' + String(run.status) + '. Partial data is not sent onward.');\n  if (iteration >= 47 || now - context.startedAtMs >= 270000) return fail('Polling limit reached. No new run was started. Check Apify Console and resume this same run ID; its platform timeout remains in force.');\n  return { route: 'poll', runId: run.id, pollNumber: iteration + 1 };\n};\nreturn [{json:inspectRun($input.first().json,$('Validate settings').first().json,$('Run context - retain this ID').first().json,$runIndex,readResponse)}];"
      }
    },
    {
      "id": "success",
      "name": "Run succeeded?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2160,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "success-condition",
              "leftValue": "={{ $json.route === 'success' }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "pending",
      "name": "Poll again?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        2160,
        560
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "pending-condition",
              "leftValue": "={{ $json.route === 'poll' }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "wait",
      "name": "Wait 5 seconds",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        1880,
        560
      ],
      "parameters": {
        "resume": "timeInterval",
        "amount": 5,
        "unit": "seconds"
      }
    },
    {
      "id": "failure",
      "name": "Stop and inspect run",
      "type": "n8n-nodes-base.stopAndError",
      "typeVersion": 1,
      "position": [
        2440,
        560
      ],
      "parameters": {
        "errorType": "errorMessage",
        "errorMessage": "={{ $json.reason + ' Run ID: ' + $json.runId }}"
      }
    },
    {
      "id": "summary",
      "name": "Fetch run SUMMARY",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        2400,
        300
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/key-value-stores/' + $json.storeId + '/records/SUMMARY' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "autodetect",
              "outputPropertyName": "body"
            }
          },
          "timeout": 15000
        }
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "summary-check",
      "name": "Require clean SUMMARY",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        2640,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const readResponse = function readResponse(response, statuses = [200]) {\n  if (!response || !statuses.includes(response.statusCode)) throw Error('HTTP request failed or returned an unexpected status. Inspect the node; do not automatically repeat a launch.');\n  const contentType = Object.entries(response.headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1];\n  if (typeof contentType !== 'string' || !/^application\\/json(?:\\s*;|$)/i.test(contentType)) throw Error('Expected JSON; redirects and HTML responses are not accepted.');\n  const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;\n  if (body == null || typeof body !== 'object') throw Error('Response has no JSON object or array.');\n  return body;\n};\nconst inspectSummary = function inspectSummary(response, config, run, read) {\n  const s = read(response);\n  const object = v => v && typeof v === 'object' && !Array.isArray(v);\n  if (!object(s) || !object(s.failedSources) || Object.keys(s.failedSources).length) throw Error('SUMMARY is missing or reports a failed source. Inspect the run before delivering rows.');\n  for (const key of ['stoppedOnChargeLimit','stoppedOnTime','stoppedEarlyOnTimeLimit','baselineSkippedOnDeadline','historyCommitSkipped']) if (s[key]) throw Error('SUMMARY reports an operational stop or history failure: ' + key + '. Do not present partial data as a clean check.');\n  let count;\n  if (config.kind === 'permits') {\n    const labels = {chicago:'Chicago, IL',san_francisco:'San Francisco, CA',new_york:'New York, NY',austin:'Austin, TX',seattle:'Seattle, WA',los_angeles:'Los Angeles, CA',baton_rouge:'Baton Rouge, LA',cincinnati:'Cincinnati, OH',mesa:'Mesa, AZ',marin_county:'Marin County, CA'};\n    count = s.total;\n    if (s.stoppedEarlyOnTimeLimit !== false || s.stoppedOnChargeLimit !== false || s.datasetItemsSaved !== count || !object(s.perCity) || Object.values(s.perCity).some(v => !Number.isSafeInteger(v) || v < 0) || Object.values(s.perCity).reduce((a,b)=>a+b,0) !== count || config.actorInput.cities.some(city => !(labels[city] in s.perCity))) throw Error('Permit SUMMARY counts or selected-source coverage are inconsistent.');\n  } else {\n    count = s.rowsSaved;\n    if (s.status !== 'ok' || s.incomplete !== false || s.stoppedOnChargeLimit !== false || s.stoppedOnTime !== false) throw Error('Actor SUMMARY does not confirm a clean bounded observation.');\n  }\n  if (!Number.isSafeInteger(count) || count < 0 || count > config.rowLimit) throw Error('Saved count is invalid or exceeds this template’s bounded row cap.');\n  const boundedSample = config.kind === 'permits' ? Object.values(s.perCity).some(v => v >= config.actorInput.maxResultsPerCity) : s.stoppedOnMaxResults === true || (s.sourcesNotReached?.length ?? 0) > 0 || (s.sourceCoverage ?? []).some(v => v.hasMore || v.publicVariantLimitReached);\n  return { ...run, summary: s, expectedRows: count, boundedSample, coverageNote: 'Only the inspected source window is represented. Never infer removals, no exposure, or complete coverage from missing rows.' };\n};\nreturn [{json:inspectSummary($input.first().json,$('Validate settings').first().json,$('Run succeeded?').first().json,readResponse)}];"
      }
    },
    {
      "id": "dataset-meta",
      "name": "Read dataset count",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        2880,
        300
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/datasets/' + $json.datasetId }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "autodetect",
              "outputPropertyName": "body"
            }
          },
          "timeout": 15000
        }
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "count-check",
      "name": "Require exact saved count",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3120,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const readResponse = function readResponse(response, statuses = [200]) {\n  if (!response || !statuses.includes(response.statusCode)) throw Error('HTTP request failed or returned an unexpected status. Inspect the node; do not automatically repeat a launch.');\n  const contentType = Object.entries(response.headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1];\n  if (typeof contentType !== 'string' || !/^application\\/json(?:\\s*;|$)/i.test(contentType)) throw Error('Expected JSON; redirects and HTML responses are not accepted.');\n  const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;\n  if (body == null || typeof body !== 'object') throw Error('Response has no JSON object or array.');\n  return body;\n};\nconst inspectDataset = function inspectDataset(response, accepted, read) {\n  const dataset = read(response).data;\n  if (!dataset || dataset.id !== accepted.datasetId || !Number.isSafeInteger(dataset.itemCount) || dataset.itemCount !== accepted.expectedRows) throw Error('Dataset count does not match SUMMARY. Stop and inspect; do not silently truncate or rerun the Actor.');\n  return accepted;\n};\nreturn [{json:inspectDataset($input.first().json,$('Require clean SUMMARY').first().json,readResponse)}];"
      }
    },
    {
      "id": "rows",
      "name": "Fetch bounded dataset",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.5,
      "position": [
        3360,
        300
      ],
      "parameters": {
        "method": "GET",
        "url": "={{ 'https://api.apify.com/v2/datasets/' + $json.datasetId + '/items?format=json&clean=false&offset=0&limit=26' }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {
          "redirect": {
            "redirect": {
              "followRedirects": false
            }
          },
          "response": {
            "response": {
              "fullResponse": true,
              "neverError": true,
              "responseFormat": "autodetect",
              "outputPropertyName": "body"
            }
          },
          "timeout": 15000
        }
      },
      "retryOnFail": false,
      "onError": "continueRegularOutput"
    },
    {
      "id": "row-check",
      "name": "Validate complete saved rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        3600,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const readResponse = function readResponse(response, statuses = [200]) {\n  if (!response || !statuses.includes(response.statusCode)) throw Error('HTTP request failed or returned an unexpected status. Inspect the node; do not automatically repeat a launch.');\n  const contentType = Object.entries(response.headers ?? {}).find(([k]) => k.toLowerCase() === 'content-type')?.[1];\n  if (typeof contentType !== 'string' || !/^application\\/json(?:\\s*;|$)/i.test(contentType)) throw Error('Expected JSON; redirects and HTML responses are not accepted.');\n  const body = typeof response.body === 'string' ? JSON.parse(response.body) : response.body;\n  if (body == null || typeof body !== 'object') throw Error('Response has no JSON object or array.');\n  return body;\n};\nconst validateRows = function validateRows(response, accepted, config, read) {\n  const rows = read(response);\n  if (!Array.isArray(rows) || rows.length !== accepted.expectedRows || rows.length > config.rowLimit) throw Error('Dataset response is incomplete or does not match the saved count.');\n  const seen = new Set();\n  const scalar = v => (typeof v === 'string' && v.trim() && !/^(?:null|undefined|n\\/a)$/i.test(v.trim())) || (typeof v === 'number' && Number.isSafeInteger(v));\n  // n8n’s isolated Code runner does not expose the URL global. These are output-only links, never fetched.\n  const url = v => typeof v === 'string' && v.length <= 4000 && /^https?:\\/\\/(?![^/?#]*@)[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::[0-9]{1,5})?(?:[/?#][^\\s]*)?$/i.test(v);\n  for (const row of rows) {\n    if (!row || typeof row !== 'object' || Array.isArray(row)) throw Error('Dataset contains a non-record value.');\n    let key;\n    if (config.kind === 'shopify') {\n      if (!url(row.storeUrl) || !url(row.productUrl) || !scalar(row.variantId) || !scalar(row.productId) || typeof row.title !== 'string' || !row.title.trim() || typeof row.price !== 'string' || !/^\\d+(?:\\.\\d+)?$/.test(row.price) || typeof row.available !== 'boolean') throw Error('Shopify row has no usable variant identity, price or availability.');\n      key = JSON.stringify([row.storeUrl, String(row.variantId)]);\n    } else if (config.kind === 'permits') {\n      if (!scalar(row.sourceCity) || !scalar(row.permitId) || (row.sourceUrl != null && row.sourceUrl !== '' && !url(row.sourceUrl))) throw Error('Permit row has no usable source/permit identity or has an invalid source URL.');\n      key = JSON.stringify([row.sourceCity, String(row.permitId)]);\n    } else {\n      if (typeof row.cveId !== 'string' || !/^CVE-\\d{4}-\\d{4,}$/.test(row.cveId) || !url(row.url) || !row.title || !row.requiredAction) throw Error('CISA row has no usable CVE identity or required source fields.');\n      key = row.cveId;\n    }\n    if (seen.has(key)) throw Error('Duplicate record identity: inspect before destination updates.');\n    seen.add(key);\n  }\n  return { ...accepted, rows, outcome: rows.length ? 'rows_ready' : 'valid_empty' };\n};\nreturn [{json:validateRows($input.first().json,$('Require exact saved count').first().json,$('Validate settings').first().json,readResponse)}];"
      }
    },
    {
      "id": "has-rows",
      "name": "Has business rows?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.2,
      "position": [
        3840,
        300
      ],
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 2
          },
          "conditions": [
            {
              "id": "has-rows-condition",
              "leftValue": "={{ $json.outcome === 'rows_ready' }}",
              "rightValue": true,
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              }
            }
          ],
          "combinator": "and"
        },
        "options": {}
      }
    },
    {
      "id": "flat",
      "name": "Sheets-ready rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4080,
        300
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const flattenRows = function flattenRows(accepted, kind) {\n  const safe = v => {\n    if (v == null) return '';\n    if (typeof v === 'object') v = JSON.stringify(v);\n    return typeof v === 'string' && /^\\s*[=+@-]/.test(v) ? \"'\" + v : v;\n  };\n  const fields = kind === 'shopify' ? ['storeUrl','variantId','productId','title','variantTitle','productUrl','price','compareAtPrice','currency','available','comparisonStatus','changeTypes','changedFields','previous','observedAt']\n    : kind === 'permits' ? ['sourceCity','permitId','permitType','permitClass','status','workDescription','address','city','state','zip','valuation','contractorName','issueDate','appliedDate','sourceUrl','scrapedAt']\n    : ['cveId','title','vendor','product','dateAdded','publishedDueDate','requiredAction','knownRansomwareCampaignUse','matchedWatchlist','comparisonStatus','changedFields','previous','url','observedAt'];\n  return accepted.rows.map(row => {\n    const recordKey = kind === 'shopify' ? JSON.stringify([row.storeUrl,String(row.variantId)]) : kind === 'permits' ? JSON.stringify([row.sourceCity,String(row.permitId)]) : row.cveId;\n    return {json:{record_key:recordKey,replay_key:JSON.stringify([accepted.runId,recordKey]),apify_run_id:accepted.runId,bounded_sample:accepted.boundedSample,...Object.fromEntries(fields.map(k=>[k,safe(row[k])]))}};\n  });\n};\nreturn flattenRows($input.first().json,\"permits\");"
      }
    },
    {
      "id": "empty",
      "name": "Valid empty - keep prior records",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        4080,
        560
      ],
      "parameters": {
        "mode": "runOnceForAllItems",
        "jsCode": "const a=$input.first().json;return [{json:{outcome:'valid_empty',apify_run_id:a.runId,rows:0,summary:a.summary,note:'No business rows to deliver. Preserve prior destination records; empty does not prove removal, safety or exhaustive coverage.'}}];"
      }
    },
    {
      "id": "setup-note",
      "name": "Setup before running",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        220,
        -120
      ],
      "parameters": {
        "width": 700,
        "height": 280,
        "content": "## Manual starter · free template, paid Actor usage\n1. Edit the small input. Keep the result cap at 1–25 total rows.\n2. Create Header Auth: name `Authorization`, value `Bearer <your Apify token>`. Select it on all 5 HTTP Request nodes. No token or credential is included.\n3. Review current Actor pricing. Each NEW run requests a $0.15 charge ceiling, 256 MiB and 180-second timeout. n8n hosting may also cost money.\n4. Run once manually. Do not execute Launch in isolation or retry a failed whole execution blindly.\nhttps://apify.com/maydit/us-building-permits-scraper"
      }
    },
    {
      "id": "replay-note",
      "name": "Replay and history",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        1550,
        -120
      ],
      "parameters": {
        "width": 760,
        "height": 240,
        "content": "## Retain the run ID before delivery\nPaste an existing run ID into settings to skip launch and reread that same run. After an uncertain POST, inspect Console before any retry.\nShopify/CISA: choose your own snapshotName and use it sequentially; no history reset is performed. History may advance before destination delivery.\nAt most 48 status reads, 5 seconds between reads, 270-second polling deadline; workflow timeout 360 seconds. Network/API errors stop, never relaunch."
      }
    },
    {
      "id": "scope-note",
      "name": "Understand these rows",
      "type": "n8n-nodes-base.stickyNote",
      "typeVersion": 1,
      "position": [
        3370,
        -120
      ],
      "parameters": {
        "width": 740,
        "height": 280,
        "content": "## Scope and destination\nFive recent published Chicago permits. Add your own issue-date window if needed. Source records are not qualified leads or buying intent. Destination deduplication is required; this Actor has no native change-only mode.\nOnly clean SUMMARY + exact dataset count + usable unique identities reach Sheets-ready rows. Result caps are labeled bounded_sample; operational partials stop.\nConnect your own Sheets Append or Update Row using record_key, writing values literally (RAW). Keep replay_key for delivery deduplication. No Google Sheets connection or schedule is included. Review retention of execution data."
      }
    }
  ],
  "connections": {
    "Start manually": {
      "main": [
        [
          {
            "node": "Edit small input",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Edit small input": {
      "main": [
        [
          {
            "node": "Validate settings",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate settings": {
      "main": [
        [
          {
            "node": "Start a new run?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Start a new run?": {
      "main": [
        [
          {
            "node": "Launch once - costs apply",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Use existing run ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Launch once - costs apply": {
      "main": [
        [
          {
            "node": "Keep launch run ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Keep launch run ID": {
      "main": [
        [
          {
            "node": "Run context - retain this ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Use existing run ID": {
      "main": [
        [
          {
            "node": "Run context - retain this ID",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run context - retain this ID": {
      "main": [
        [
          {
            "node": "Read run status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read run status": {
      "main": [
        [
          {
            "node": "Inspect run status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Inspect run status": {
      "main": [
        [
          {
            "node": "Run succeeded?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Run succeeded?": {
      "main": [
        [
          {
            "node": "Fetch run SUMMARY",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Poll again?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Poll again?": {
      "main": [
        [
          {
            "node": "Wait 5 seconds",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Stop and inspect run",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 5 seconds": {
      "main": [
        [
          {
            "node": "Read run status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch run SUMMARY": {
      "main": [
        [
          {
            "node": "Require clean SUMMARY",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Require clean SUMMARY": {
      "main": [
        [
          {
            "node": "Read dataset count",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read dataset count": {
      "main": [
        [
          {
            "node": "Require exact saved count",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Require exact saved count": {
      "main": [
        [
          {
            "node": "Fetch bounded dataset",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Fetch bounded dataset": {
      "main": [
        [
          {
            "node": "Validate complete saved rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Validate complete saved rows": {
      "main": [
        [
          {
            "node": "Has business rows?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Has business rows?": {
      "main": [
        [
          {
            "node": "Sheets-ready rows",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Valid empty - keep prior records",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "settings": {
    "executionOrder": "v1",
    "executionTimeout": 360,
    "saveManualExecutions": true
  },
  "pinData": {},
  "tags": []
}
