Cómo construir un Google Apps Script para importar los contactos de una Sheet a Brevo
Empuja contactos de un Google Sheet a Brevo de forma automática. Un Apps Script completo con almacenamiento de la API key, ejecución on-edit, disparadores temporales, un menú personalizado y manejo de errores, sin servidor.
Si tu equipo ya vive en un Google Sheet (leads de ventas, registros de eventos, una lista de contactos de partners), llevar esos datos a Brevo no tiene por qué pasar por exportar CSVs y reimportarlos a mano. Google Apps Script te permite enchufar la Sheet directamente a la API de Brevo. El script corre dentro de la infraestructura de Google, así que no hay nada que alojar, desplegar ni vigilar.
Esta guía recorre un script funcional: un elemento de menú personalizado Sync to Brevo en tu Sheet, un disparador horario automático, almacenamiento seguro de la API key, manejo de lotes y un poco de logging estructurado para que sepas qué ha pasado.
Lo que necesitas
- Una Google Sheet con contactos (una fila por contacto, fila de cabecera primero)
- Una cuenta de Brevo y una API key (Configuración → SMTP & API → API Keys)
- El ID numérico de la lista de Brevo a la que quieres añadir los contactos
Eso es todo. Sin npm, sin Python, sin servidor.
Estructura de la hoja
El script de esta guía espera una fila de cabecera seguida de un contacto por fila. Las columnas se mapean por el nombre de la cabecera a los atributos de Brevo:
| firstName | lastName | company | city | |
|---|---|---|---|---|
| [email protected] | Jane | Doe | Acme | Berlin |
| [email protected] | John | Smith | Globex | Paris |
email es obligatorio y se compara sin distinguir mayúsculas y minúsculas. Todo lo demás se envía a Brevo como atributo de contacto. Los atributos personalizados (cualquier cosa más allá de los estándar como FIRSTNAME, LASTNAME) tienen que existir antes en tu cuenta de Brevo, defínelos en Contactos → Configuración → Atributos de contacto, o vía la API de Brevo.
Abrir el editor de Apps Script
En tu Sheet: Extensiones → Apps Script. Se abre una nueva pestaña con un Code.gs en blanco. Reemplaza el contenido por el script de abajo.
El script completo
const BREVO_API_BASE = 'https://api.brevo.com/v3';const BREVO_LIST_ID = 42; // <- the Brevo list to import contacts intoconst SHEET_NAME = 'Contacts'; // <- name of the sheet tab to readconst BATCH_SIZE = 1000; // contacts per import call
/** * Adds a "Brevo" menu to the Sheet so users can run the sync from the UI. * Triggered automatically when the Sheet opens. */function onOpen() { SpreadsheetApp.getUi() .createMenu('Brevo') .addItem('Sync sheet to Brevo', 'syncSheetToBrevo') .addItem('Configure API key', 'configureApiKey') .addToUi();}
/** * Reads every contact row from the sheet, batches them, and sends each batch * to Brevo's import endpoint. Returns a summary string for logging. */function syncSheetToBrevo() { const apiKey = getApiKey_(); if (!apiKey) { SpreadsheetApp.getUi().alert( 'No Brevo API key configured. Run "Configure API key" first.' ); return; }
const contacts = readContactsFromSheet_(); if (contacts.length === 0) { SpreadsheetApp.getUi().alert('No contacts found in the sheet.'); return; }
const batches = chunk_(contacts, BATCH_SIZE); const results = [];
for (let i = 0; i < batches.length; i++) { const result = importBatchToBrevo_(apiKey, batches[i]); results.push(result); Logger.log( `Batch ${i + 1}/${batches.length}: ${result.ok ? 'ok' : 'FAILED'} ` + `(processId=${result.processId || '-'}, status=${result.status})` ); }
const summary = `Sent ${contacts.length} contacts in ${batches.length} batch(es). ` + `Successful: ${results.filter(r => r.ok).length}/${results.length}.`; Logger.log(summary); SpreadsheetApp.getActiveSpreadsheet().toast(summary, 'Brevo sync', 5); return summary;}
/** * Reads the active spreadsheet's "Contacts" tab into an array of * { email, attributes } objects shaped for Brevo's jsonBody. */function readContactsFromSheet_() { const sheet = SpreadsheetApp .getActiveSpreadsheet() .getSheetByName(SHEET_NAME); if (!sheet) { throw new Error(`Sheet tab "${SHEET_NAME}" not found`); }
const range = sheet.getDataRange().getValues(); if (range.length < 2) return [];
const headers = range[0].map(String); const emailColumn = headers.findIndex(h => h.toLowerCase() === 'email'); if (emailColumn === -1) { throw new Error('Sheet must have an "email" column'); }
const contacts = []; for (let i = 1; i < range.length; i++) { const row = range[i]; const email = String(row[emailColumn] || '').trim().toLowerCase(); if (!email || !email.includes('@')) continue; // skip invalid
const attributes = {}; for (let c = 0; c < headers.length; c++) { if (c === emailColumn) continue; const value = row[c]; if (value === '' || value === null) continue; // Brevo convention: ATTRIBUTES ARE UPPERCASE attributes[headers[c].toUpperCase()] = value; } contacts.push({ email, attributes }); } return contacts;}
/** * POSTs a batch of contacts to Brevo's import endpoint. * Returns { ok, status, processId, error }. */function importBatchToBrevo_(apiKey, contacts) { const payload = { jsonBody: contacts, listIds: [BREVO_LIST_ID], updateExistingContacts: true, emptyContactsAttributes: false, };
const response = UrlFetchApp.fetch(`${BREVO_API_BASE}/contacts/import`, { method: 'post', contentType: 'application/json', headers: { 'api-key': apiKey, 'accept': 'application/json', }, payload: JSON.stringify(payload), muteHttpExceptions: true, // we'll inspect status ourselves });
const status = response.getResponseCode(); const body = response.getContentText();
if (status === 202) { const json = JSON.parse(body); return { ok: true, status, processId: json.processId }; } return { ok: false, status, error: body };}
/** * Stores the Brevo API key in script properties, encrypted at rest by Google * and not visible in the source code or to viewers of the sheet. */function configureApiKey() { const ui = SpreadsheetApp.getUi(); const response = ui.prompt( 'Brevo API key', 'Paste your Brevo API key (xkeysib-...). It will be stored in Script Properties.', ui.ButtonSet.OK_CANCEL ); if (response.getSelectedButton() !== ui.Button.OK) return; const key = response.getResponseText().trim(); if (!key.startsWith('xkeysib-')) { ui.alert('That doesn\'t look like a Brevo API key (should start with xkeysib-).'); return; } PropertiesService.getScriptProperties().setProperty('BREVO_API_KEY', key); ui.alert('API key saved.');}
function getApiKey_() { return PropertiesService.getScriptProperties().getProperty('BREVO_API_KEY');}
function chunk_(arr, size) { const out = []; for (let i = 0; i < arr.length; i += size) out.push(arr.slice(i, i + size)); return out;}Eso es todo. Guárdalo (⌘S / Ctrl+S), llama al proyecto algo así como “Brevo sync” y vuelve a tu Sheet.
Primera ejecución
Recarga la Sheet, aparece el nuevo menú Brevo arriba.
- Haz clic en Brevo → Configure API key, pega tu clave
xkeysib-..., haz clic en OK. - Haz clic en Brevo → Sync sheet to Brevo. Google te pedirá permisos la primera vez:
- “Ver y gestionar tus hojas de cálculo”, necesario para leer las filas
- “Conectar a un servicio externo”, necesario para llamar a api.brevo.com
- Aprueba. El script corre. Un toast verde abajo a la derecha te dice cuántos contactos han salido.
Si falla, haz clic en Extensiones → Apps Script → Ver → Registros para ver el código de estado por lote desde Brevo. El fallo más común es un 400 por un atributo personalizado que falta, mira la sección de troubleshooting más abajo.
Ejecutarlo de forma programada
En el editor de Apps Script: Disparadores (el icono de reloj en la barra lateral izquierda) → Añadir disparador.
- Función a ejecutar:
syncSheetToBrevo - Origen del evento: Basado en tiempo
- Tipo: temporizador horario (o diario para una sincronización una vez al día)
- Intervalo: cada hora (o lo que encaje)
Guardar. Google ejecutará la función con esa cadencia para siempre, sin servidor, sin cron, sin mantenimiento.
También puedes usar Desde la hoja → Al editar si quieres que cada cambio de celda dispare una sincronización. Cuidado con eso, hasta los cambios cosméticos disparan el trigger, lo que puede agotar rápido la cuota diaria de Apps Script en hojas muy activas. El disparador horario es casi siempre la respuesta correcta.
Cuotas de Apps Script que conviene conocer
El nivel gratuito de Apps Script tiene límites que vale la pena respetar:
| Límite | Valor (nivel gratuito) |
|---|---|
| Tiempo total de ejecución por día | 90 minutos |
| Tiempo de una ejecución | 6 minutos |
Llamadas de UrlFetchApp por día | 20.000 |
Tamaño de payload de UrlFetchApp | 50 MB |
Tamaño de cabeceras de UrlFetchApp | 8 KB |
| Disparadores por usuario y script | 20 |
Para una sincronización típica de contactos (unos miles de contactos, cada hora) estás muy lejos de cualquiera de ellos. El único a vigilar es el de 6 minutos por ejecución única, si alguna vez sincronizas cientos de miles de contactos de golpe, divídelos en bloques más pequeños (el script de arriba ya lo hace vía BATCH_SIZE).
Manejar la importación de forma asíncrona
El endpoint de importación de Brevo es asíncrono: recibes un processId al instante y la importación real corre en el servidor. Para la mayoría de las sincronizaciones de Sheet esto está bien, dispara y olvida, Brevo enviará un email resumen cuando cada lote acabe.
Si quieres bloquear hasta que la importación esté de verdad terminada, sondea el endpoint de estado del proceso:
function waitForImport_(apiKey, processId, timeoutMs = 5 * 60 * 1000) { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const resp = UrlFetchApp.fetch(`${BREVO_API_BASE}/processes/${processId}`, { headers: { 'api-key': apiKey }, muteHttpExceptions: true, }); if (resp.getResponseCode() === 200) { const status = JSON.parse(resp.getContentText()).status; if (status === 'completed' || status === 'failed') return status; } Utilities.sleep(5000); // 5s between checks } return 'timeout';}Utilities.sleep es el equivalente en Apps Script de una espera bloqueante. No duermas demasiado, tienes 6 minutos en total por ejecución.
Añadir un webhook de notificación
Un patrón más limpio que el sondeo: despliega tu Apps Script como Aplicación web y pasa su URL como notifyUrl. Brevo hará POST a ella cuando la importación termine.
// Add to Code.gsfunction doPost(e) { const payload = JSON.parse(e.postData.contents); Logger.log(`Brevo import ${payload.processId} finished: ${payload.status}`); // optionally: write the result back to a "Sync log" tab in the sheet return ContentService.createTextOutput('ok');}Despliega: Implementar → Nueva implementación → Aplicación web, pon “Quién tiene acceso” en Cualquiera, copia la URL resultante y pásala como notifyUrl en tu payload de importación:
payload.notifyUrl = 'https://script.google.com/macros/s/AKfy.../exec';Ahora Brevo publica el resultado de vuelta al script de tu propia Sheet, cerrando el bucle sin infraestructura externa.
Troubleshooting
400 Bad Request con error: "Attribute X not found". Una columna de tu Sheet apunta a un atributo que Brevo no conoce. O renombras la columna de la Sheet para que coincida con un atributo existente, o creas el atributo en Brevo (Contactos → Configuración → Atributos de contacto).
401 Unauthorized. La API key es incorrecta o ha expirado. Vuelve a ejecutar Configure API key, pega una clave nueva del panel de Brevo.
429 Too Many Requests. Estás chocando contra el rate limit de Brevo. El endpoint de importación permite unas 30 llamadas por minuto. Si estás haciendo lotes de forma agresiva, añade Utilities.sleep(2000) entre lotes en el bucle.
El script no se ejecuta de forma silenciosa en el horario previsto. Revisa Disparadores en el editor de Apps Script. Si un disparador falla repetidamente, Google lo desactiva. Haz clic dentro del disparador para ver el motivo del fallo, normalmente un problema de permisos que puedes reautorizar.
El menú Brevo no apareció. onOpen solo corre cuando (re)abres la Sheet desde cero. Recarga la pestaña del navegador.
El popup de permisos vuelve una y otra vez. Probablemente has editado los scopes del script (añadido un nuevo servicio de Google). Apps Script vuelve a pedir autorización cada vez que cambian los permisos requeridos. Ejecuta cualquier función una vez desde el editor para disparar el prompt y aprobar.
Por qué esto le gana a Zapier y compañía
Apps Script es gratis, vive dentro de la infraestructura de Google y tiene acceso directo a los datos de la Sheet, sin disparo de eventos fila a fila, sin precios por tarea, sin rate limits aparte de la cuota de Google (que es generosa para este tipo de tarea). La contrapartida: te comprometes a escribir y mantener un trozo pequeño de código. Para una sincronización de contactos, eso son unas 100 líneas y básicamente cero mantenimiento continuo.
Empareja esto con un disparador diario y una hoja que tu equipo de ventas ya está actualizando, y tienes una pipeline de contactos hacia Brevo con cero trabajo recurrente.