{
  "name": "Verify contractor licenses in your lead list (17 US states)",
  "nodes": [
    {
      "id": "b0000000-0000-4000-8000-000000000099",
      "name": "Sticky: overview",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -1600,
        -620
      ],
      "parameters": {
        "color": 1,
        "width": 680,
        "height": 2000,
        "content": "## Verify contractor licenses in your lead list (17 US states)\n\n![Workflow overview](https://scrapebench.dev/img/n8n/license-verify-lead-list/canvas.png)\n\n> **Self-hosted only.** This template uses the `@apify/n8n-nodes-apify` community node, which cannot be installed on n8n Cloud.\n\n### Who's it for\nAnyone sitting on a list of contractors \u2014 agencies selling to home services, sales teams prospecting, marketplaces admitting trade accounts. Lead lists scraped from Google Maps tell you a company exists. They do not tell you whether its licence is real, current, or issued in the state it claims to work in.\n\n### What it does\nEach lead is routed to its state's licensing portal, the returned licence is matched back to the lead, and the lead is sorted into verified or needs-review. It returns the licence number, status and expiry date, and one of four verdicts: `verified-active`, `verified-expired`, `review` (an active licence matched, but only partially on name) or `not-found`.\n\nLeads from the other 33 states are labelled `unverifiable - state not covered` and passed through. They are never dropped and never marked unlicensed.\n\n### How to set up\n1. Install the `@apify/n8n-nodes-apify` community node.\n2. Add your Apify API key as an Apify credential on the **Look up the licence** node.\n3. Replace the **Your leads** node with your own source (Google Sheets, CRM, CSV). Each lead needs `company` and a 2-letter `state`; `license_number` is optional and makes the lookup exact.\n\n### Requirements\nAn Apify account (free tier works) and a self-hosted n8n.\n\n### How to customize\nAdd a state by adding one row to the `ACTORS` map in **Route by state**. Change the matching strictness in **Match licence to lead** \u2014 it deliberately flags partial name matches for review rather than passing them.\n\nCovered: AL AR CA CT FL MA MI MN NC NM NV OR SC TN TX VA WA."
      },
      "typeVersion": 1
    },
    {
      "id": "b0000000-0000-4000-8000-00000000000a",
      "name": "Sticky: leads",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -760,
        -620
      ],
      "parameters": {
        "color": 7,
        "width": 320,
        "height": 400,
        "content": "## 1. Your leads\nSwap this Set node for a **Google Sheets \u2192 Get rows** node (or any source).\n\nEach lead needs:\n- `company` \u2014 the business or licensee name\n- `state` \u2014 2-letter code\n\n`license_number` is optional. If you have it, the lookup is exact."
      },
      "typeVersion": 1
    },
    {
      "id": "b0000000-0000-4000-8000-00000000000b",
      "name": "Sticky: routing",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        -420,
        -620
      ],
      "parameters": {
        "color": 7,
        "width": 380,
        "height": 400,
        "content": "## 2. Route each lead to its state's actor\nOne Code node replaces a 17-way Switch. It maps the state to the right Apify actor **and** to that actor's own input field \u2014 they are not uniform:\n\n- most take `query`\n- New Mexico takes `businessName`\n- South Carolina takes `lastName`\n- Virginia takes `nameFilter`\n- Connecticut goes through the multi-state actor\n\nAdd a state by adding one row to `ACTORS`."
      },
      "typeVersion": 1
    },
    {
      "id": "b0000000-0000-4000-8000-00000000000c",
      "name": "Sticky: uncovered",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        100,
        300
      ],
      "parameters": {
        "color": 7,
        "width": 360,
        "height": 260,
        "content": "## 3. Uncovered states pass through\nA lead outside the 17 covered states is labelled `unverifiable - state not covered`.\n\nIt is **never dropped** and **never marked unlicensed**. \"We didn't look\" is not the same as \"they aren't licensed\"."
      },
      "typeVersion": 1
    },
    {
      "id": "b0000000-0000-4000-8000-00000000000d",
      "name": "Sticky: verdict",
      "type": "n8n-nodes-base.stickyNote",
      "position": [
        620,
        -620
      ],
      "parameters": {
        "color": 7,
        "width": 360,
        "height": 320,
        "content": "## 4. The verdict\n`not-found` means the state's portal returned no match for that name. It does **not** mean the contractor is unlicensed \u2014 they may be licensed under a different legal name, or in another state.\n\nOnly `verified-active` is a positive result."
      },
      "typeVersion": 1
    },
    {
      "id": "a1000000-0000-4000-8000-000000000001",
      "name": "When clicking Execute",
      "type": "n8n-nodes-base.manualTrigger",
      "position": [
        -700,
        0
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "a1000000-0000-4000-8000-000000000002",
      "name": "Your leads",
      "type": "n8n-nodes-base.code",
      "position": [
        -480,
        0
      ],
      "parameters": {
        "jsCode": "// Replace with your real leads (Google Sheets, CRM, CSV...).\nreturn [\n  { json: { company: 'BENITEZ, MICHAEL', state: 'FL', license_number: 'CGC1522906' } },\n  { json: { company: 'SMITH', state: 'CA', license_number: '' } },\n  { json: { company: 'ACME BUILDERS', state: 'ND', license_number: '' } },\n];"
      },
      "typeVersion": 2
    },
    {
      "id": "a1000000-0000-4000-8000-000000000003",
      "name": "Route by state",
      "type": "n8n-nodes-base.code",
      "position": [
        -260,
        0
      ],
      "parameters": {
        "jsCode": "// state -> { actor, field }\n// `field` matters: these actors do NOT share one input name.\nconst ACTORS = {\n  AL: { actor: 'scrapebench~alabama-contractor-license-lookup',        field: 'query' },\n  AR: { actor: 'scrapebench~arkansas-contractor-license-lookup',       field: 'query' },\n  CA: { actor: 'scrapebench~california-contractor-license-lookup',     field: 'query' },\n  CT: { actor: 'scrapebench~multistate-contractor-license-lookup',     field: 'query', extra: { states: ['CT'] } },\n  FL: { actor: 'scrapebench~florida-contractor-license-lookup',        field: 'query' },\n  MA: { actor: 'scrapebench~massachusetts-construction-license-lookup', field: 'query' },\n  MI: { actor: 'scrapebench~michigan-contractor-license-lookup',       field: 'query' },\n  MN: { actor: 'scrapebench~minnesota-contractor-license-lookup',      field: 'query' },\n  NC: { actor: 'scrapebench~north-carolina-contractor-license-lookup', field: 'query' },\n  NM: { actor: 'scrapebench~new-mexico-contractor-license-lookup',     field: 'businessName' },\n  NV: { actor: 'scrapebench~nevada-contractor-license-lookup',         field: 'query' },\n  OR: { actor: 'scrapebench~oregon-contractor-license-lookup',         field: 'query' },\n  SC: { actor: 'scrapebench~south-carolina-contractor-license-lookup', field: 'lastName' },\n  TN: { actor: 'scrapebench~tennessee-contractor-license-lookup',      field: 'query' },\n  TX: { actor: 'scrapebench~texas-contractor-license-lookup',          field: 'query' },\n  VA: { actor: 'scrapebench~virginia-contractor-license-lookup',       field: 'nameFilter' },\n  WA: { actor: 'scrapebench~washington-contractor-license-lookup',     field: 'query' },\n};\n\nreturn $input.all().map((item) => {\n  const lead = item.json;\n  const state = String(lead.state || '').trim().toUpperCase();\n  const cfg = ACTORS[state];\n\n  if (!cfg) {\n    // Not covered. Pass it through labelled - never drop it, never call it unlicensed.\n    return { json: { ...lead, covered: false, verification_status: 'unverifiable - state not covered' } };\n  }\n\n  // Prefer an exact licence number when the lead has one; otherwise search the name.\n  const term = String(lead.license_number || '').trim() || String(lead.company || '').trim();\n  const input = { [cfg.field]: term, maxResults: 5, ...(cfg.extra || {}) };\n\n  return { json: { ...lead, covered: true, actorId: cfg.actor, actorInput: JSON.stringify(input) } };\n});"
      },
      "typeVersion": 2
    },
    {
      "id": "a1000000-0000-4000-8000-000000000004",
      "name": "Covered state?",
      "type": "n8n-nodes-base.if",
      "position": [
        -40,
        0
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "caseSensitive": true
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "c1",
              "operator": {
                "type": "boolean",
                "operation": "true",
                "singleValue": true
              },
              "leftValue": "={{ $json.covered }}",
              "rightValue": ""
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "a1000000-0000-4000-8000-000000000005",
      "name": "Look up the licence",
      "type": "@apify/n8n-nodes-apify.apify",
      "onError": "continueRegularOutput",
      "position": [
        220,
        -120
      ],
      "parameters": {
        "actorId": {
          "__rl": true,
          "mode": "id",
          "value": "={{ $json.actorId }}"
        },
        "resource": "Actors",
        "operation": "Run actor and get dataset",
        "customBody": "={{ $json.actorInput }}"
      },
      "typeVersion": 1,
      "alwaysOutputData": true
    },
    {
      "id": "a1000000-0000-4000-8000-000000000006",
      "name": "Match licence to lead",
      "type": "n8n-nodes-base.code",
      "position": [
        460,
        -120
      ],
      "parameters": {
        "jsCode": "// Pair each returned licence back to the lead that asked for it, then judge it.\n//\n// Matching is deliberately conservative: exact licence number if we had one, otherwise a\n// normalised name comparison. A near-miss is reported as `review`, never as a pass.\nconst norm = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]/g, '');\n\nconst leads = $('Route by state').all().map((i) => i.json).filter((l) => l.covered);\nconst rows = $input.all().map((i) => i.json);\n\nconst out = [];\nfor (const lead of leads) {\n  const wanted = String(lead.license_number || '').trim();\n  const hits = rows.filter((r) => {\n    if (wanted) return String(r.license_number || '').trim().toUpperCase() === wanted.toUpperCase();\n    const a = norm(lead.company);\n    const b = norm(r.business_name || r.company_name || r.person_name);\n    return a && b && (a === b || b.includes(a) || a.includes(b));\n  });\n\n  if (!hits.length) {\n    // NOT the same as \"unlicensed\" - say exactly what happened.\n    out.push({ json: { ...lead, verification_status: 'not-found', note: 'No match at the state portal for this name.' } });\n    continue;\n  }\n\n  const hit = hits[0];\n  const status = String(hit.status || '').toLowerCase();\n  const active = status.includes('active') || status.includes('current');\n  const exactName = !wanted && norm(lead.company) === norm(hit.business_name || hit.company_name || hit.person_name);\n\n  let verdict;\n  if (!active) verdict = 'verified-expired';\n  else if (wanted || exactName) verdict = 'verified-active';\n  else verdict = 'review'; // active licence, but the name only partially matched\n\n  out.push({ json: {\n    ...lead,\n    verification_status: verdict,\n    license_number: hit.license_number ?? lead.license_number ?? null,\n    license_status: hit.status ?? null,\n    license_expiry: hit.expiration_date ?? null,\n    matched_name: hit.business_name ?? hit.person_name ?? null,\n    source_url: hit.source_url ?? null,\n  } });\n}\nreturn out;"
      },
      "typeVersion": 2
    },
    {
      "id": "a1000000-0000-4000-8000-000000000007",
      "name": "Not covered - keep the lead",
      "type": "n8n-nodes-base.set",
      "position": [
        220,
        140
      ],
      "parameters": {
        "options": {},
        "assignments": {
          "assignments": [
            {
              "id": "u1",
              "name": "verification_status",
              "type": "string",
              "value": "unverifiable - state not covered"
            }
          ]
        },
        "includeOtherFields": true
      },
      "typeVersion": 3.4
    },
    {
      "id": "a1000000-0000-4000-8000-000000000008",
      "name": "Licensed and active?",
      "type": "n8n-nodes-base.if",
      "position": [
        700,
        -120
      ],
      "parameters": {
        "options": {},
        "conditions": {
          "options": {
            "version": 2,
            "caseSensitive": true
          },
          "combinator": "and",
          "conditions": [
            {
              "id": "v1",
              "operator": {
                "type": "string",
                "operation": "equals"
              },
              "leftValue": "={{ $json.verification_status }}",
              "rightValue": "verified-active"
            }
          ]
        }
      },
      "typeVersion": 2
    },
    {
      "id": "a1000000-0000-4000-8000-000000000009",
      "name": "Verified leads",
      "type": "n8n-nodes-base.noOp",
      "position": [
        940,
        -220
      ],
      "parameters": {},
      "typeVersion": 1
    },
    {
      "id": "a1000000-0000-4000-8000-000000000010",
      "name": "Needs review",
      "type": "n8n-nodes-base.noOp",
      "position": [
        940,
        -20
      ],
      "parameters": {},
      "typeVersion": 1
    }
  ],
  "settings": {
    "executionOrder": "v1"
  },
  "connections": {
    "Your leads": {
      "main": [
        [
          {
            "node": "Route by state",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Covered state?": {
      "main": [
        [
          {
            "node": "Look up the licence",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Not covered - keep the lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by state": {
      "main": [
        [
          {
            "node": "Covered state?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Look up the licence": {
      "main": [
        [
          {
            "node": "Match licence to lead",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Licensed and active?": {
      "main": [
        [
          {
            "node": "Verified leads",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Needs review",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match licence to lead": {
      "main": [
        [
          {
            "node": "Licensed and active?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "When clicking Execute": {
      "main": [
        [
          {
            "node": "Your leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}