Verificación masiva de correos en n8n con Google Sheets
- Para quién
- Usuarios de n8n con listas de leads en Google Sheets
- El problema
- Llamar a una API de verificación una vez por fila es lento y choca con los límites de peticiones, mientras que una API masiva necesita una tarea, un bucle de sondeo y una forma fiable de devolver cada resultado a su fila.
- La solución
- Un solo flujo toma hasta 5 000 filas que aún no tienen resultado, envía sus direcciones únicas como una sola tarea masiva de Cold Leads, consulta la tarea cada 5 segundos, descarga los resultados, enruta cada fila por estado y escribe en la hoja el estado, la puntuación, el motivo y un siguiente paso. Vuelva a ejecutarlo para las 5 000 siguientes.
- Qué obtiene
- Cuatro columnas rellenas por fila (verify_status, verify_score, verify_reason, next_step) y un flujo que puede volver a ejecutar hasta completar toda la hoja.
Las direcciones, los identificadores y los resultados de los ejemplos son ilustrativos. example.com está reservado para documentación, así que una comprobación real de estas direcciones devuelve invalid.
Los ejemplos de código son iguales en todos los idiomas; sus comentarios están en inglés.
Qué hace el flujo
- Read leads (Google Sheets): todas las filas de la hoja, cada una con su row_number.
- Pick next batch (Code): filas sin verify_status, direcciones sin espacios sobrantes y en minúsculas, sin duplicados ni celdas que no sean direcciones de correo, como máximo 5 000. Si no queda nada, el flujo se detiene aquí.
- Create job (HTTP Request): POST /api/v1/verify/bulk con las direcciones. La respuesta es 202 con el id de la tarea.
- Wait 5 s y después Get job status (HTTP Request): GET /api/v1/verify/jobs/{id}. Finished? (If) devuelve el flujo a la espera hasta que el estado sea done o failed.
- Get results (HTTP Request): la misma petición con results=1, que añade una entrada por dirección: email, status, score y reason.
- Match rows (Code): cada fila de la hoja cuya dirección está en los resultados, duplicados incluidos, con su row_number.
- Route by status (Switch): valid, risky e invalid van cada uno a su propia actualización de Google Sheets, que escribe las tres columnas de resultado y next_step (send, review o do not send).
Prepare la hoja y las credenciales
- La hoja necesita una fila de encabezado con las columnas email, verify_status, verify_score, verify_reason y next_step (las demás columnas pueden quedarse).
- Una credencial de Google Sheets en n8n con acceso a la hoja de cálculo.
- Una credencial Header Auth para Cold Leads: Name x-api-key, Value su clave secreta (sk_…) de Ajustes → Claves de API. La API forma parte del plan Business.
El flujo
Copie el JSON y péguelo en un lienzo vacío de n8n, o guárdelo como archivo e impórtelo. Después abra los cuatro nodos de Google Sheets, sustituya la URL de la hoja de cálculo y el nombre de la hoja (Leads) por los suyos y seleccione su credencial de Google, y seleccione la credencial Header Auth de Cold Leads en los tres nodos HTTP Request.
{
"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"
}
}Cómo funciona el bucle de sondeo
- Cada petición de estado también verifica el siguiente lote de hasta 25 direcciones antes de responder, y un proceso programado hace avanzar las tareas abiertas en segundo plano, así que el bucle hace avanzar la tarea en lugar de solo observarla.
- Una petición cada 5 segundos son 12 por minuto, muy por debajo del límite de 120 peticiones por minuto por clave.
- El estado failed significa que la tarea se detuvo antes de tiempo, normalmente porque se acabaron los créditos. Sus resultados hasta ese momento se escriben igualmente; las filas sin resultado conservan verify_status vacío y las recoge la siguiente ejecución.
- Cada ejecución abre una sola tarea. Una cuenta puede tener cinco tareas en cola o en curso a la vez, incluidas las iniciadas en la aplicación web; por encima de eso, Create job falla con 429 too_many_jobs.
Enrutado y escritura de resultados
| verify_status | next_step | Valores típicos de verify_reason |
|---|---|---|
| valid | send | ok (un servidor de correo aceptó el buzón) o smtp_unreachable (el dominio acepta correo, el buzón no se comprobó) |
| risky | review | catch_all, smtp_unknown o un dominio desechable (puntuación de 40 o menos) |
| invalid | do not send | mailbox_missing, no_mx o syntax |
- Las filas se actualizan por row_number, así que los duplicados y las direcciones que mezclan mayúsculas y minúsculas reciben todos el resultado de su única comprobación.
- Las celdas que no son direcciones de correo nunca se envían y conservan verify_status vacío; fíltrelas o corríjalas en la hoja.
- Para actuar sobre un grupo, añada nodos tras su salida del Switch, por ejemplo para pasar las filas válidas a una herramienta de campañas.
Límites y costes
- 1 crédito por dirección única de una tarea, sea cual sea el resultado, incluidos los resultados de la caché de 30 días. Los duplicados y las celdas que no son direcciones se eliminan antes de crear la tarea y no cuestan nada.
- Hasta 5 000 direcciones por tarea, cinco tareas abiertas por cuenta, 120 peticiones por minuto por clave. Los resultados de las tareas solo llegan en JSON.
- Los créditos se comprueban al crear la tarea (402 no_credits si el saldo es menor que la tarea) y se cobran a medida que se procesan las direcciones.
- El plan Business cuesta $99 al mes e incluye la API y 10 000 créditos al mes; los paquetes extra de 1 000 créditos cuestan $5. Una hoja de 20 000 filas con 20 000 direcciones únicas necesita 20 000 créditos: si aún no se ha usado ninguno de los 10 000 del mes, esos más 10 paquetes ($50).
Preguntas frecuentes
¿Cómo verifico más de 5 000 filas?
Vuelva a ejecutar el flujo: cada ejecución toma las 5 000 filas siguientes sin verify_status. Si lo programa, elija un intervalo más largo de lo que tarda una tarea, para que dos ejecuciones nunca tomen las mismas filas; se cobrarían dos veces.
¿Hay un nodo de Cold Leads para n8n?
No. El flujo usa el nodo estándar HTTP Request con una credencial Header Auth; no hace falta nada más.
¿Por qué una ejecución terminó con el estado failed?
La cuenta se quedó sin créditos durante la tarea. Los resultados que tenía se escriben; añada créditos en Ajustes → Facturación y vuelva a ejecutar el flujo para terminar las filas restantes.
¿Puedo comprobar las direcciones de una en una?
Sí, con un nodo HTTP Request que llame a POST /api/v1/verify para cada elemento. Cuesta lo mismo, 1 crédito por dirección, pero cada dirección es una petición propia, y una clave admite 120 peticiones por minuto.