Как написать Google Apps Script для импорта контактов из таблицы в Brevo
Автоматическая загрузка контактов из Google-таблицы в Brevo. Полный Apps Script со хранением API-ключа, запуском при редактировании, триггерами по времени, кастомным меню и обработкой ошибок. Сервер не нужен.
Если Ваша команда уже живёт в Google-таблице (лиды по продажам, регистрации на мероприятия, список партнёрских контактов), то получение этих данных в Brevo не должно сводиться к экспорту CSV и ручному реимпорту. Google Apps Script позволяет соединить таблицу напрямую с Brevo API. Скрипт работает в инфраструктуре Google, так что хостить, разворачивать и обслуживать ничего не нужно.
В этом руководстве рассмотрен рабочий скрипт: пункт меню Sync to Brevo в Вашей таблице, автоматический почасовой триггер, безопасное хранение API-ключа, обработка батчей и немного структурированного логирования, чтобы Вы понимали, что произошло.
Что Вам понадобится
- Google-таблица с контактами (одна строка на контакт, заголовки в первой строке)
- Аккаунт Brevo и API-ключ (Settings → SMTP & API → API Keys)
- Числовой ID списка Brevo, в который нужно добавлять контакты
И всё. Ни npm, ни Python, ни сервера.
Структура листа
Скрипт в этом руководстве ожидает строку заголовка, за которой идут строки контактов. Столбцы сопоставляются по имени заголовка с атрибутами Brevo:
| firstName | lastName | company | city | |
|---|---|---|---|---|
| [email protected] | Jane | Doe | Acme | Berlin |
| [email protected] | John | Smith | Globex | Paris |
email обязателен и сравнивается без учёта регистра. Всё остальное отправляется в Brevo как атрибут контакта. Пользовательские атрибуты (всё за пределами стандартных вроде FIRSTNAME, LASTNAME) должны существовать в Вашем аккаунте Brevo заранее, заведите их в Contacts → Settings → Contact attributes или через Brevo API.
Открыть редактор Apps Script
В Вашей таблице: Extensions → Apps Script. Откроется новая вкладка с пустым Code.gs. Замените содержимое на скрипт ниже.
Полный скрипт
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;}И всё. Сохраните (⌘S / Ctrl+S), назовите проект, например, “Brevo sync” и возвращайтесь к таблице.
Первый запуск
Перезагрузите таблицу: вверху появится новое меню Brevo.
- Кликните Brevo → Configure API key, вставьте Ваш ключ
xkeysib-..., нажмите OK. - Кликните Brevo → Sync sheet to Brevo. Google запросит разрешения при первом запуске:
- “View and manage your spreadsheets” - нужно для чтения строк
- “Connect to an external service” - нужно для вызова api.brevo.com
- Подтвердите. Скрипт запустится. Зелёный тост в правом нижнем углу скажет, сколько контактов отправилось.
Если что-то не получилось, кликните Extensions → Apps Script → View → Logs, чтобы увидеть код статуса от Brevo по каждому батчу. Самая частая причина сбоя - 400 из-за отсутствующего пользовательского атрибута, см. раздел про устранение неполадок ниже.
Запуск по расписанию
В редакторе Apps Script: Triggers (иконка часов в левой панели) → Add Trigger.
- Choose function:
syncSheetToBrevo - Event source: Time-driven
- Type: Hour timer (или Day timer для синхронизации раз в сутки)
- Interval: каждый час (или как удобно)
Сохраните. Google будет запускать функцию по этому расписанию вечно, без сервера, без cron, без обслуживания.
Можно также использовать From spreadsheet → On edit, если хотите, чтобы каждое изменение ячейки запускало синхронизацию. Будьте осторожны: даже косметические правки сработают триггером, что быстро съест дневную квоту Apps Script на активных таблицах. Триггер по часам - почти всегда правильный ответ.
Квоты Apps Script, которые стоит знать
У бесплатного тарифа Apps Script есть лимиты, которые имеет смысл уважать:
| Лимит | Значение (бесплатный тариф) |
|---|---|
| Общее время выполнения в сутки | 90 минут |
| Время одного выполнения | 6 минут |
Вызовов UrlFetchApp в сутки | 20 000 |
Размер полезной нагрузки UrlFetchApp | 50 MB |
Размер заголовков UrlFetchApp | 8 KB |
| Триггеров на пользователя на скрипт | 20 |
Для типичной синхронизации контактов (несколько тысяч контактов раз в час) Вы и близко не подойдёте ни к одному из них. Единственный, за которым стоит следить, - 6 минут на одно выполнение: если будете синхронизировать сотни тысяч контактов за раз, разбейте их на меньшие батчи (скрипт выше уже делает это через BATCH_SIZE).
Обработка асинхронного импорта
Эндпоинт импорта Brevo асинхронный: Вы получаете processId сразу, а сам импорт идёт на стороне сервера. Для большинства синхронизаций таблиц этого достаточно: отправил и забыл, Brevo пришлёт сводку по email, когда каждый батч завершится.
Если хотите блокировать выполнение, пока импорт реально не завершится, опросите эндпоинт статуса процесса:
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 - это эквивалент блокирующего ожидания в Apps Script. Не спите слишком долго, у Вас всего 6 минут на одно выполнение.
Добавление notify-вебхука
Более чистый паттерн, чем опрос: разверните Apps Script как Web App и передавайте его URL как notifyUrl. Brevo отправит на него POST по завершении импорта.
// 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');}Развернуть: Deploy → New deployment → Web app, поставьте “Who has access” в Anyone, скопируйте получившийся URL и передавайте его как notifyUrl в нагрузке импорта:
payload.notifyUrl = 'https://script.google.com/macros/s/AKfy.../exec';Теперь Brevo возвращает результат обратно в скрипт самой таблицы, замыкая цикл без внешней инфраструктуры.
Устранение неполадок
400 Bad Request с error: "Attribute X not found". Столбец Вашей таблицы соответствует атрибуту, о котором Brevo не знает. Либо переименуйте столбец так, чтобы он совпадал с существующим атрибутом, либо создайте атрибут в Brevo (Contacts → Settings → Contact attributes).
401 Unauthorized. API-ключ неверный или просрочен. Перезапустите Configure API key и вставьте свежий ключ из дашборда Brevo.
429 Too Many Requests. Вы упёрлись в лимит частоты Brevo. Эндпоинт импорта допускает примерно 30 вызовов в минуту. Если активно бьёте батчами, добавьте Utilities.sleep(2000) между батчами в цикле.
Скрипт молча не запускается по расписанию. Проверьте Triggers в редакторе Apps Script. Если триггер постоянно падает, Google его отключает. Кликните по триггеру, чтобы увидеть причину сбоя, обычно проблема с разрешениями, которую можно переподтвердить.
Меню Brevo не появилось. onOpen запускается только когда Вы (пере)открываете таблицу с нуля. Перезагрузите вкладку браузера.
Окно с разрешениями всё время возвращается. Скорее всего, Вы изменили scopes скрипта (добавили новый сервис Google). Apps Script перезапрашивает авторизацию каждый раз, когда меняются необходимые разрешения. Запустите любую функцию из редактора, чтобы вызвать запрос и подтвердить.
Почему это лучше Zapier и подобных
Apps Script бесплатен, живёт в инфраструктуре Google и имеет прямой доступ к данным таблицы: нет посрочных событий по строкам, нет посрочной оплаты, нет лимитов кроме квот Google (которые щедры для такой задачи). Обратная сторона: Вы берёте на себя написание и поддержку небольшого куска кода. Для синхронизации контактов это около 100 строк и почти нулевое последующее обслуживание.
Соедините это с ежедневным триггером и таблицей, которую отдел продаж и так обновляет, и Вы получите контактный конвейер в Brevo с нулевой повторяющейся работой.