Cold Leads

Масова перевірка e-mail в n8n з Google Sheets

Для кого
Користувачі n8n, які ведуть списки лідів у Google Sheets
Проблема
Викликати API перевірки окремо для кожного рядка повільно, і так швидко впираєшся в ліміти запитів, а масовому API потрібні завдання, цикл опитування та надійний спосіб зіставити результати з потрібними рядками.
Рішення
Один workflow бере до 5 000 рядків, які ще не мають результату, надсилає їхні унікальні адреси одним масовим завданням Cold Leads, опитує завдання кожні 5 секунд, отримує результати, спрямовує кожен рядок за статусом і записує в таблицю статус, оцінку, причину та наступний крок. Запустіть його знову для наступних 5 000.
Що ви отримаєте
Чотири заповнені колонки в кожному рядку (verify_status, verify_score, verify_reason, next_step) і workflow, який можна запускати знову, доки не буде оброблено всю таблицю.

Адреси, ідентифікатори та результати в прикладах ілюстративні. Домен example.com зарезервовано для документації, тож реальна перевірка цих адрес поверне invalid.

Приклади коду однакові для всіх мов, коментарі в них — англійською.

Що робить workflow

  1. Read leads (Google Sheets): усі рядки аркуша, кожен зі своїм row_number.
  2. Pick next batch (Code): рядки без verify_status; адреси очищено від зайвих пробілів і переведено в нижній регістр, дублікати й клітинки, що не є e-mail-адресами, прибрано; не більше 5 000. Якщо нічого не лишилося, workflow зупиняється тут.
  3. Create job (HTTP Request): POST /api/v1/verify/bulk з адресами. Відповідь — 202 з id завдання.
  4. Wait 5 s, потім Get job status (HTTP Request): GET /api/v1/verify/jobs/{id}. Finished? (If) повертає потік до очікування, доки статус не стане done або failed.
  5. Get results (HTTP Request): той самий запит із results=1, який додає по одному запису на адресу: email, status, score і reason.
  6. Match rows (Code): кожен рядок аркуша, чия адреса є в результатах, включно з дублікатами, з його row_number.
  7. Route by status (Switch): valid, risky та invalid ідуть кожен у своє оновлення Google Sheets, яке записує три колонки результату й next_step (send, review або do not send).

Підготуйте таблицю й облікові дані

  • Аркушу потрібен рядок заголовків із колонками email, verify_status, verify_score, verify_reason і next_step (інші колонки можуть лишитися).
  • Облікові дані (credential) Google Sheets в n8n з доступом до таблиці.
  • Облікові дані Header Auth для Cold Leads: Name — x-api-key, Value — ваш секретний ключ (sk_…) з Налаштування → API-ключі. API входить у тариф Business.

Workflow

Скопіюйте JSON і вставте його на порожнє полотно n8n або збережіть як файл та імпортуйте. Потім відкрийте чотири вузли Google Sheets, замініть URL таблиці й назву аркуша (Leads) на свої та виберіть свої облікові дані Google, а в трьох вузлах HTTP Request виберіть облікові дані Header Auth для Cold Leads.

cold-leads-bulk-verification.json
{
  "name": "Cold Leads: bulk-verify a Google Sheet",
  "nodes": [
    {
      "parameters": {},
      "id": "2fbec667-d7ba-567e-90cc-f8f765280c69",
      "name": "Manual Trigger",
      "type": "n8n-nodes-base.manualTrigger",
      "typeVersion": 1,
      "position": [
        0,
        300
      ]
    },
    {
      "parameters": {
        "documentId": {
          "__rl": true,
          "value": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID/edit",
          "mode": "url"
        },
        "sheetName": {
          "__rl": true,
          "value": "Leads",
          "mode": "name"
        },
        "options": {}
      },
      "id": "fee88922-46ce-5cc3-adeb-fdea47dee333",
      "name": "Read leads",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        220,
        300
      ]
    },
    {
      "parameters": {
        "jsCode": "// Up to 5,000 unique addresses from rows that have no verify_status yet\nconst EMAIL_COLUMN = 'email'; // header of the column that holds the addresses\nconst EMAIL = /^[a-z0-9!#$%&'*+/=?^_`{|}~.-]+@[a-z0-9.-]+\\.[a-z]{2,}$/;\nconst batch = new Set();\nfor (const row of $input.all()) {\n  if (row.json.verify_status) continue;\n  const email = String(row.json[EMAIL_COLUMN] ?? '').trim().toLowerCase();\n  if (EMAIL.test(email)) batch.add(email);\n  if (batch.size === 5000) break;\n}\nif (batch.size === 0) return []; // nothing left to check: the run ends here\nreturn [{ json: { emails: [...batch], name: 'Google Sheet ' + new Date().toISOString().slice(0, 16) } }];"
      },
      "id": "22648923-73f7-5ad0-9048-b3c8ab82bceb",
      "name": "Pick next batch",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        440,
        300
      ]
    },
    {
      "parameters": {
        "method": "POST",
        "url": "https://coldleads.app/api/v1/verify/bulk",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "sendBody": true,
        "specifyBody": "json",
        "jsonBody": "={{ JSON.stringify({ emails: $json.emails, name: $json.name }) }}",
        "options": {}
      },
      "id": "21d28c08-d217-5483-a469-7d8e1bb2b55c",
      "name": "Create job",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        660,
        300
      ]
    },
    {
      "parameters": {
        "resume": "timeInterval",
        "amount": 5,
        "unit": "seconds"
      },
      "id": "027cfbd6-d040-569f-891b-27e82afb10dc",
      "name": "Wait 5 s",
      "type": "n8n-nodes-base.wait",
      "typeVersion": 1.1,
      "position": [
        880,
        300
      ],
      "webhookId": "bcfa0fa7-0ddb-5ca6-b4cd-8f8683f5d382"
    },
    {
      "parameters": {
        "url": "=https://coldleads.app/api/v1/verify/jobs/{{ $('Create job').first().json.id }}",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "id": "c89c51ab-9e45-5c03-a7cd-6e07f77ba4a1",
      "name": "Get job status",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        1100,
        300
      ]
    },
    {
      "parameters": {
        "conditions": {
          "options": {
            "caseSensitive": true,
            "leftValue": "",
            "typeValidation": "strict",
            "version": 3
          },
          "conditions": [
            {
              "id": "bd05712a-13ee-5582-b4f0-395e0b0fbd68",
              "leftValue": "={{ $json.status }}",
              "rightValue": "done",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            },
            {
              "id": "704c81e4-6294-54fe-acba-fb6b793532ae",
              "leftValue": "={{ $json.status }}",
              "rightValue": "failed",
              "operator": {
                "type": "string",
                "operation": "equals"
              }
            }
          ],
          "combinator": "or"
        },
        "options": {}
      },
      "id": "53044579-5bee-56e5-be83-ac265b532b1f",
      "name": "Finished?",
      "type": "n8n-nodes-base.if",
      "typeVersion": 2.3,
      "position": [
        1320,
        300
      ]
    },
    {
      "parameters": {
        "url": "=https://coldleads.app/api/v1/verify/jobs/{{ $('Create job').first().json.id }}?results=1",
        "authentication": "genericCredentialType",
        "genericAuthType": "httpHeaderAuth",
        "options": {}
      },
      "id": "510e5c01-080d-5c15-9efc-217a39166c2f",
      "name": "Get results",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 4.3,
      "position": [
        1540,
        200
      ]
    },
    {
      "parameters": {
        "jsCode": "// Every sheet row whose address is in the job results, duplicates included\nconst EMAIL_COLUMN = 'email';\nconst results = $input.first().json.results ?? [];\nconst byEmail = new Map(results.map((r) => [r.email, r]));\nconst out = [];\nfor (const row of $('Read leads').all()) {\n  const r = byEmail.get(String(row.json[EMAIL_COLUMN] ?? '').trim().toLowerCase());\n  if (r) {\n    out.push({ json: { row_number: row.json.row_number, verify_status: r.status, verify_score: r.score, verify_reason: r.reason } });\n  }\n}\nreturn out;"
      },
      "id": "e2a52d57-ceb5-5001-8583-05108037b70e",
      "name": "Match rows",
      "type": "n8n-nodes-base.code",
      "typeVersion": 2,
      "position": [
        1760,
        200
      ]
    },
    {
      "parameters": {
        "rules": {
          "values": [
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 3
                },
                "conditions": [
                  {
                    "id": "acffb8aa-dc93-5aa6-a4be-faaad80ee30d",
                    "leftValue": "={{ $json.verify_status }}",
                    "rightValue": "valid",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "valid"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 3
                },
                "conditions": [
                  {
                    "id": "1c9e110f-be13-5915-99eb-4effd919e365",
                    "leftValue": "={{ $json.verify_status }}",
                    "rightValue": "risky",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "risky"
            },
            {
              "conditions": {
                "options": {
                  "caseSensitive": true,
                  "leftValue": "",
                  "typeValidation": "strict",
                  "version": 3
                },
                "conditions": [
                  {
                    "id": "d7c0e0eb-7605-5aae-a8ba-44763748f248",
                    "leftValue": "={{ $json.verify_status }}",
                    "rightValue": "invalid",
                    "operator": {
                      "type": "string",
                      "operation": "equals"
                    }
                  }
                ],
                "combinator": "and"
              },
              "renameOutput": true,
              "outputKey": "invalid"
            }
          ]
        },
        "options": {}
      },
      "id": "05d0a0b1-98ee-51d2-94a9-70bca0ca8679",
      "name": "Route by status",
      "type": "n8n-nodes-base.switch",
      "typeVersion": 3.4,
      "position": [
        1980,
        200
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID/edit",
          "mode": "url"
        },
        "sheetName": {
          "__rl": true,
          "value": "Leads",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $json.row_number }}",
            "verify_status": "={{ $json.verify_status }}",
            "verify_score": "={{ $json.verify_score }}",
            "verify_reason": "={{ $json.verify_reason }}",
            "next_step": "send"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": [
            {
              "id": "verify_status",
              "displayName": "verify_status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "verify_score",
              "displayName": "verify_score",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "verify_reason",
              "displayName": "verify_reason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "next_step",
              "displayName": "next_step",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "displayName": "row_number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true,
              "readOnly": true,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "e6d03101-c54b-5b31-a792-fc2ebb2e77b0",
      "name": "Write valid",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        2200,
        60
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID/edit",
          "mode": "url"
        },
        "sheetName": {
          "__rl": true,
          "value": "Leads",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $json.row_number }}",
            "verify_status": "={{ $json.verify_status }}",
            "verify_score": "={{ $json.verify_score }}",
            "verify_reason": "={{ $json.verify_reason }}",
            "next_step": "review"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": [
            {
              "id": "verify_status",
              "displayName": "verify_status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "verify_score",
              "displayName": "verify_score",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "verify_reason",
              "displayName": "verify_reason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "next_step",
              "displayName": "next_step",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "displayName": "row_number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true,
              "readOnly": true,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "2961af6e-c325-5235-a349-13ff161cd9e9",
      "name": "Write risky",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        2200,
        220
      ]
    },
    {
      "parameters": {
        "operation": "update",
        "documentId": {
          "__rl": true,
          "value": "https://docs.google.com/spreadsheets/d/YOUR_SPREADSHEET_ID/edit",
          "mode": "url"
        },
        "sheetName": {
          "__rl": true,
          "value": "Leads",
          "mode": "name"
        },
        "columns": {
          "mappingMode": "defineBelow",
          "value": {
            "row_number": "={{ $json.row_number }}",
            "verify_status": "={{ $json.verify_status }}",
            "verify_score": "={{ $json.verify_score }}",
            "verify_reason": "={{ $json.verify_reason }}",
            "next_step": "do not send"
          },
          "matchingColumns": [
            "row_number"
          ],
          "schema": [
            {
              "id": "verify_status",
              "displayName": "verify_status",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "verify_score",
              "displayName": "verify_score",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "verify_reason",
              "displayName": "verify_reason",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "next_step",
              "displayName": "next_step",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "string",
              "canBeUsedToMatch": true
            },
            {
              "id": "row_number",
              "displayName": "row_number",
              "required": false,
              "defaultMatch": false,
              "display": true,
              "type": "number",
              "canBeUsedToMatch": true,
              "readOnly": true,
              "removed": false
            }
          ],
          "attemptToConvertTypes": false,
          "convertFieldsToString": false
        },
        "options": {}
      },
      "id": "521d6f36-f36a-5009-ace8-f22cf8924b43",
      "name": "Write invalid",
      "type": "n8n-nodes-base.googleSheets",
      "typeVersion": 4.7,
      "position": [
        2200,
        380
      ]
    }
  ],
  "connections": {
    "Manual Trigger": {
      "main": [
        [
          {
            "node": "Read leads",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Read leads": {
      "main": [
        [
          {
            "node": "Pick next batch",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Pick next batch": {
      "main": [
        [
          {
            "node": "Create job",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Create job": {
      "main": [
        [
          {
            "node": "Wait 5 s",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Wait 5 s": {
      "main": [
        [
          {
            "node": "Get job status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get job status": {
      "main": [
        [
          {
            "node": "Finished?",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Finished?": {
      "main": [
        [
          {
            "node": "Get results",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Wait 5 s",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Get results": {
      "main": [
        [
          {
            "node": "Match rows",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Match rows": {
      "main": [
        [
          {
            "node": "Route by status",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Route by status": {
      "main": [
        [
          {
            "node": "Write valid",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Write risky",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "Write invalid",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  },
  "pinData": {},
  "settings": {
    "executionOrder": "v1"
  }
}

Як працює цикл опитування

  • Кожен запит статусу перед відповіддю ще й перевіряє наступну порцію до 25 адрес, а обробник за розкладом просуває відкриті завдання у фоні, тож цикл рухає завдання вперед, а не лише спостерігає за ним.
  • Один запит кожні 5 секунд — це 12 на хвилину, значно менше за ліміт у 120 запитів на хвилину на ключ.
  • status failed означає, що завдання зупинилося достроково, зазвичай тому, що закінчилися кредити. Отримані до того результати все одно записуються; рядки без результату зберігають порожній verify_status, і їх підхопить наступний запуск.
  • За один запуск відкривається лише одне завдання. Акаунт може мати одночасно п’ять завдань у черзі або в роботі, включно із запущеними у веб-застосунку; понад це Create job завершується помилкою 429 too_many_jobs.

Розподіл і запис результатів

verify_statusnext_stepТипові значення verify_reason
validsendok (поштовий сервер прийняв скриньку) або smtp_unreachable (домен приймає пошту, скриньку не перевірено)
riskyreviewcatch_all, smtp_unknown або одноразовий домен (оцінка 40 чи нижче)
invaliddo not sendmailbox_missing, no_mx або syntax
  • Рядки оновлюються за row_number, тож усі дублікати й адреси в різному регістрі в таблиці отримують результат своєї єдиної перевірки.
  • Клітинки, що не є e-mail-адресами, ніколи не надсилаються і зберігають порожній verify_status; відфільтруйте їх або виправте в таблиці.
  • Щоб обробити кошик, додайте вузли після відповідного виходу Switch, наприклад щоб передавати рядки valid в інструмент для розсилок.

Ліміти та вартість

  • 1 кредит за кожну унікальну адресу в завданні, незалежно від результату, зокрема й за результати з 30-денного кешу. Дублікати й клітинки, що не є адресами, прибираються до створення завдання й нічого не коштують.
  • До 5 000 адрес у завданні, п’ять відкритих завдань на акаунт, 120 запитів на хвилину на ключ. Результати завдань повертаються лише як JSON.
  • Кредити перевіряються під час створення завдання (402 no_credits, якщо баланс менший за завдання) і списуються в міру обробки адрес.
  • Тариф Business коштує $99 на місяць і містить API та 10 000 кредитів на місяць; додаткові пакети по 1 000 кредитів коштують $5. Таблиці з 20 000 рядків і 20 000 унікальних адрес потрібно 20 000 кредитів: якщо з місячних 10 000 ще не використано жодного — ці 10 000 плюс 10 пакетів ($50).

Питання

Як перевірити понад 5 000 рядків?

Запустіть workflow знову: кожен запуск бере наступні 5 000 рядків без verify_status. Якщо запускаєте його за розкладом, оберіть інтервал, довший за тривалість завдання, щоб два запуски ніколи не взяли ті самі рядки: за них заплатили б двічі.

Чи є вузол Cold Leads для n8n?

Ні. Workflow використовує стандартний вузол HTTP Request з обліковими даними Header Auth; більше нічого не потрібно.

Чому запуск завершився зі status failed?

Під час завдання в акаунті закінчилися кредити. Уже отримані результати записано; докупіть кредити в Налаштування → Оплата і знову запустіть workflow, щоб обробити решту рядків.

А можна перевіряти по одній адресі?

Так, за допомогою вузла HTTP Request, що викликає POST /api/v1/verify для кожного елемента. Це так само коштує 1 кредит за адресу, але кожна адреса — окремий запит, а ключ дозволяє 120 запитів на хвилину.