Cold Leads

Hromadné ověřování e-mailů v n8n s Google Sheets

Pro koho
Uživatelé n8n se seznamy leadů v Google Sheets
Problém
Volat ověřovací API pro každý řádek zvlášť je pomalé a naráží na limity požadavků, zatímco hromadné API potřebuje úlohu, smyčku dotazů na stav a spolehlivý způsob, jak výsledky přiřadit zpět ke správným řádkům.
Řešení
Jedno workflow vezme až 5 000 řádků, které ještě nemají výsledek, pošle jejich unikátní adresy jako jednu hromadnou úlohu Cold Leads, každých 5 sekund se dotáže na její stav, stáhne výsledky, každý řádek nasměruje podle stavu a do listu zapíše stav, skóre, důvod a další krok. Pro dalších 5 000 ho spusťte znovu.
Co získáte
Čtyři vyplněné sloupce v každém řádku (verify_status, verify_score, verify_reason, next_step) a workflow, které můžete spouštět znovu, dokud nebude hotový celý list.

Adresy, identifikátory a výsledky v příkladech jsou ilustrativní. Doména example.com je vyhrazená pro dokumentaci, takže skutečná kontrola těchto adres vrátí invalid.

Ukázky kódu jsou ve všech jazycích stejné, komentáře v nich jsou anglicky.

Co workflow dělá

  1. Read leads (Google Sheets): všechny řádky listu, každý se svým row_number.
  2. Pick next batch (Code): řádky bez verify_status, adresy bez okrajových mezer a převedené na malá písmena, bez duplicit a bez buněk, které nejsou e-mailovými adresami, nejvýš 5 000. Pokud nic nezbývá, workflow tady skončí.
  3. Create job (HTTP Request): POST /api/v1/verify/bulk s adresami. Odpověď je 202 s id úlohy.
  4. Wait 5 s, pak Get job status (HTTP Request): GET /api/v1/verify/jobs/{id}. Finished? (If) vrací tok zpět k čekání, dokud stav není done nebo failed.
  5. Get results (HTTP Request): stejný požadavek s results=1, který přidá jednu položku na adresu: email, status, score a reason.
  6. Match rows (Code): každý řádek listu, jehož adresa je ve výsledcích, včetně duplicit, s jeho row_number.
  7. Route by status (Switch): větve valid, risky a invalid vedou každá do vlastní aktualizace Google Sheets, která zapíše tři sloupce s výsledkem a next_step (send, review nebo do not send).

Připravte list a přihlašovací údaje

  • List potřebuje řádek záhlaví se sloupci email, verify_status, verify_score, verify_reason a next_step (ostatní sloupce mohou zůstat).
  • Přihlašovací údaje (credential) Google Sheets v n8n s přístupem k tabulce.
  • Přihlašovací údaje Header Auth pro Cold Leads: Name x-api-key, Value váš tajný klíč (sk_…) z Nastavení → API klíče. API je součástí tarifu Business.

Workflow

Zkopírujte JSON a vložte ho na prázdné plátno n8n, nebo ho uložte jako soubor a naimportujte. Pak otevřete čtyři uzly Google Sheets, nahraďte URL tabulky a název listu (Leads) svými a vyberte své přihlašovací údaje Google, a ve třech uzlech HTTP Request vyberte přihlašovací údaje Header Auth pro 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"
  }
}

Jak funguje smyčka dotazů na stav

  • Každý dotaz na stav navíc před odpovědí ověří další dávku až 25 adres a plánovaný worker posouvá otevřené úlohy na pozadí, takže smyčka úlohu posouvá kupředu, místo aby ji jen sledovala.
  • Jeden požadavek každých 5 sekund je 12 za minutu, hluboko pod limitem 120 požadavků za minutu na klíč.
  • Stav failed znamená, že se úloha zastavila předčasně, obvykle proto, že došly kredity. Dosavadní výsledky se přesto zapíšou; řádky bez výsledku si ponechají prázdný verify_status a vezme je další spuštění.
  • Jedno spuštění má otevřenou jen jednu úlohu. Účet může mít najednou pět úloh ve frontě nebo v běhu, včetně úloh spuštěných ve webové aplikaci; nad tento počet Create job selže s 429 too_many_jobs.

Směrování a zápis zpět

verify_statusnext_stepTypické hodnoty verify_reason
validsendok (poštovní server schránku přijal) nebo smtp_unreachable (doména přijímá poštu, schránka se nekontrolovala)
riskyreviewcatch_all, smtp_unknown nebo jednorázová doména (skóre 40 nebo nižší)
invaliddo not sendmailbox_missing, no_mx nebo syntax
  • Řádky se aktualizují podle row_number, takže duplicity i adresy s různou velikostí písmen v listu dostanou výsledek jediné kontroly své adresy.
  • Buňky, které nejsou e-mailovými adresami, se nikdy neodesílají a ponechají si prázdný verify_status; odfiltrujte je nebo je v listu opravte.
  • Chcete-li s některou skupinou dál pracovat, přidejte uzly za její výstup ze Switch, například pro přidání platných řádků do nástroje pro kampaně.

Limity a náklady

  • 1 kredit za každou unikátní adresu v úloze bez ohledu na výsledek, včetně výsledků z 30denní cache. Duplicity a buňky, které nejsou adresami, se odstraní před vytvořením úlohy a nic nestojí.
  • Až 5 000 adres na úlohu, pět otevřených úloh na účet, 120 požadavků za minutu na klíč. Výsledky úloh přicházejí jen jako JSON.
  • Kredity se kontrolují při vytvoření úlohy (402 no_credits, pokud je zůstatek menší než úloha) a strhávají se průběžně, jak se adresy zpracovávají.
  • Tarif Business stojí $99 měsíčně a zahrnuje API a 10 000 kreditů měsíčně; další balíčky po 1 000 kreditech stojí $5. List s 20 000 řádky a 20 000 unikátními adresami potřebuje 20 000 kreditů: pokud z 10 000 měsíčních kreditů ještě žádný nebyl využit, jsou to těchto 10 000 a k tomu 10 balíčků ($50).

Otázky

Jak ověřit víc než 5 000 řádků?

Spusťte workflow znovu: každé spuštění vezme dalších 5 000 řádků bez verify_status. Pokud ho plánujete, zvolte interval delší, než trvá jedna úloha, aby dvě spuštění nikdy nevzala stejné řádky; ty by se účtovaly dvakrát.

Existuje pro n8n uzel Cold Leads?

Ne. Workflow používá standardní uzel HTTP Request s přihlašovacími údaji Header Auth; nic dalšího není potřeba.

Proč spuštění skončilo stavem failed?

Účtu během úlohy došly kredity. Výsledky, které úloha měla, se zapíšou; dokupte kredity v Nastavení → Platby a spusťte workflow znovu, aby dokončilo zbývající řádky.

Můžu místo toho kontrolovat adresy po jedné?

Ano, uzlem HTTP Request, který volá POST /api/v1/verify pro každou položku. Stojí to stejně 1 kredit za adresu, ale každá adresa je samostatný požadavek a klíč povoluje 120 požadavků za minutu.