Mit Google Apps Script Kontakte aus einem Sheet zu Brevo importieren
Schiebe Kontakte automatisch aus einem Google Sheet zu Brevo. Ein vollständiges Apps Script mit API-Schlüssel-Speicherung, Run-on-Edit, Zeit-basierten Triggern, einem eigenen Menü und Fehlerbehandlung, ganz ohne Server.
Wenn dein Team ohnehin in einem Google Sheet lebt (Vertriebsleads, Event-Anmeldungen, eine Partner-Kontaktliste), muss der Weg dieser Daten in Brevo nicht über CSV-Exporte und manuelle Reimporte führen. Mit Google Apps Script verdrahtest du das Sheet direkt mit Brevos API. Das Skript läuft auf Googles Infrastruktur, also gibt es nichts zu hosten, zu deployen oder zu überwachen.
Diese Anleitung führt durch ein funktionierendes Skript: ein eigener Menüeintrag Sync to Brevo in deinem Sheet, ein automatischer stündlicher Trigger, sichere API-Schlüssel-Speicherung, Batch-Verarbeitung und ein bisschen strukturiertes Logging, damit du nachvollziehen kannst, was passiert ist.
Was du brauchst
- Ein Google Sheet mit Kontakten (eine Zeile pro Kontakt, Header-Zeile zuerst)
- Ein Brevo-Konto und einen API-Schlüssel (Einstellungen → SMTP & API → API Keys)
- Die numerische ID der Brevo-Liste, in die Kontakte aufgenommen werden sollen
Mehr nicht. Kein npm, kein Python, kein Server.
Sheet-Layout
Das Skript in dieser Anleitung erwartet eine Header-Zeile, gefolgt von einem Kontakt pro Zeile. Spalten werden über den Header-Namen den Brevo-Attributen zugeordnet:
| firstName | lastName | company | city | |
|---|---|---|---|---|
| [email protected] | Jane | Doe | Acme | Berlin |
| [email protected] | John | Smith | Globex | Paris |
email ist Pflicht und wird Groß-/Kleinschreibungs-unabhängig abgeglichen. Alles andere wird als Kontakt-Attribut an Brevo geschickt. Benutzerdefinierte Attribute (alles über die Standardmenge wie FIRSTNAME, LASTNAME hinaus) müssen vorher in deinem Brevo-Konto existieren, lege sie unter Kontakte → Einstellungen → Kontakt-Attribute oder über die Brevo-API an.
Apps-Script-Editor öffnen
In deinem Sheet: Erweiterungen → Apps Script. Ein neuer Tab öffnet sich mit einer leeren Code.gs. Ersetze den Inhalt durch das Skript unten.
Das vollständige Skript
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;}Das ist alles. Speichern (⌘S / Ctrl+S), benenne das Projekt etwa “Brevo sync” und geh zurück zu deinem Sheet.
Erster Lauf
Lade das Sheet neu, das neue Brevo-Menü erscheint oben.
- Klick Brevo → Configure API key, füg deinen
xkeysib-...-Schlüssel ein, klick OK. - Klick Brevo → Sync sheet to Brevo. Google fragt das erste Mal nach Berechtigungen:
- “Deine Tabellen anzeigen und verwalten”, nötig zum Lesen der Zeilen
- “Verbindung zu einem externen Dienst herstellen”, nötig, um api.brevo.com aufzurufen
- Genehmige sie. Das Skript läuft. Ein grüner Toast unten rechts sagt dir, wie viele Kontakte rausgegangen sind.
Wenn es scheitert, klick Erweiterungen → Apps Script → Ansicht → Logs, um den Statuscode pro Batch von Brevo zu sehen. Der häufigste Fehler ist ein 400 wegen eines fehlenden benutzerdefinierten Attributs, schau dir den Troubleshooting-Abschnitt unten an.
Nach Plan ausführen
Im Apps-Script-Editor: Trigger (das Uhren-Symbol in der linken Sidebar) → Trigger hinzufügen.
- Funktion auswählen:
syncSheetToBrevo - Ereignisquelle: Zeitgesteuert
- Typ: Stunden-Timer (oder Tages-Timer für tägliche Synchronisation)
- Intervall: jede Stunde (oder was passt)
Speichern. Google führt die Funktion in dieser Kadenz dauerhaft aus, ohne Server, ohne Cron, ohne Wartung.
Du kannst auch Aus Tabelle → On edit nutzen, wenn jeder Zellenänderung eine Synchronisation auslösen soll. Vorsicht damit, selbst kosmetische Änderungen feuern den Trigger, das kann auf belebten Sheets schnell die tägliche Apps-Script-Quote auffressen. Der stündliche Zeit-Trigger ist fast immer die richtige Antwort.
Apps-Script-Quoten, die du kennen solltest
Das Apps-Script-Free-Tier hat Limits, die einen Blick wert sind:
| Limit | Wert (Free Tier) |
|---|---|
| Gesamtlaufzeit pro Tag | 90 Minuten |
| Einzelausführungszeit | 6 Minuten |
UrlFetchApp-Aufrufe pro Tag | 20.000 |
UrlFetchApp-Payload-Größe | 50 MB |
UrlFetchApp-Header-Größe | 8 KB |
| Trigger pro Nutzer:in pro Skript | 20 |
Für eine typische Kontaktsynchronisation (ein paar tausend Kontakte, stündlich) bist du nirgendwo in der Nähe dieser Werte. Das einzige, worauf du achten solltest, sind die 6 Minuten Einzelausführung, falls du jemals Hunderttausende Kontakte auf einmal synchronisierst, batche sie in kleinere Chunks (das obige Skript macht das schon über BATCH_SIZE).
Den Import asynchron behandeln
Brevos Import-Endpoint ist asynchron: du bekommst sofort eine processId zurück, der eigentliche Import läuft serverseitig. Für die meisten Sheet-Synchronisationen ist das okay, fire-and-forget, Brevo schickt eine E-Mail-Zusammenfassung, sobald jeder Batch fertig ist.
Wenn du blockieren willst, bis der Import wirklich fertig ist, polle den Prozess-Status-Endpoint:
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 ist das Apps-Script-Äquivalent eines blockierenden Waits. Schlaf nicht zu lang, du hast nur 6 Minuten gesamt pro Ausführung.
Notify-Webhook hinzufügen
Sauberer als Polling: deploye dein Apps Script als Web App und übergib die URL als notifyUrl. Brevo POSTet daran, sobald der Import fertig ist.
// 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');}Deployen: Bereitstellen → Neue Bereitstellung → Web App, “Wer hat Zugriff” auf Alle setzen, die resultierende URL kopieren und als notifyUrl im Import-Payload mitgeben:
payload.notifyUrl = 'https://script.google.com/macros/s/AKfy.../exec';Jetzt postet Brevo das Ergebnis an das Skript deines eigenen Sheets zurück, der Kreis schließt sich ohne externe Infrastruktur.
Troubleshooting
400 Bad Request mit error: "Attribute X not found". Eine Spalte in deinem Sheet zeigt auf ein Attribut, das Brevo nicht kennt. Benenne die Sheet-Spalte um, damit sie zu einem existierenden Attribut passt, oder leg das Attribut in Brevo an (Kontakte → Einstellungen → Kontakt-Attribute).
401 Unauthorized. API-Schlüssel ist falsch oder abgelaufen. Führ Configure API key erneut aus und füg einen frischen Schlüssel aus dem Brevo-Dashboard ein.
429 Too Many Requests. Du läufst gegen Brevos Rate Limit. Der Import-Endpoint erlaubt etwa 30 Aufrufe pro Minute. Wenn du aggressiv batchst, füg Utilities.sleep(2000) zwischen Batches im Loop ein.
Skript läuft nach Plan still nicht. Prüf Trigger im Apps-Script-Editor. Wenn ein Trigger wiederholt scheitert, deaktiviert Google ihn. Klick in den Trigger, um den Fehlergrund zu sehen, meist ein Berechtigungsproblem, das du neu autorisieren kannst.
Das Brevo-Menü erscheint nicht. onOpen läuft nur, wenn du das Sheet (neu) öffnest. Lade den Browser-Tab neu.
Berechtigungs-Popup kommt immer wieder. Du hast wahrscheinlich die Scopes des Skripts geändert (einen neuen Google-Service hinzugefügt). Apps Script fragt die Autorisierung neu ab, sobald sich die nötigen Berechtigungen ändern. Führ irgendeine Funktion einmal aus dem Editor aus, um den Prompt auszulösen, und genehmige.
Warum das besser ist als Zapier und Co.
Apps Script ist gratis, lebt in Googles Infrastruktur und hat direkten Zugriff auf die Daten des Sheets, kein Event-Feuer pro Zeile, kein Pricing pro Task, keine Rate Limits außer Googles Quote (die für so einen Job großzügig ist). Die Kehrseite: du verpflichtest dich, ein kleines Stück Code zu schreiben und zu pflegen. Für eine Kontaktsynchronisation sind das etwa 100 Zeilen und im Grunde keine laufende Wartung.
Pair das mit einem täglichen Trigger und einem Sheet, das dein Sales-Team ohnehin pflegt, und du hast eine Kontakt-Pipeline in Brevo mit null wiederkehrender Arbeit.