Sådan bygger du et Google Apps Script til at importere et arks kontakter ind i Brevo
Skub kontakter fra et Google Sheet ind i Brevo automatisk. Et komplet Apps Script med API-nøgleopbevaring, run-on-edit, time-baserede triggers, en custom-menu og fejlhåndtering. Ingen server.
Hvis dit team allerede lever i et Google Sheet, salgsleads, event-tilmeldinger, en partnerkontaktliste, behøver det at få de data ind i Brevo ikke involvere CSV-eksport og manuel re-import. Google Apps Script lader dig forbinde Sheet’et direkte til Brevos API. Scriptet kører inde i Googles infrastruktur, så der er intet at hoste, deploye eller passe på.
Denne guide går igennem et fungerende script: et custom Sync to Brevo-menupunkt i dit Sheet, en automatisk hourly trigger, sikker API-nøgleopbevaring, batch-håndtering og lidt struktureret logging, så du kan se, hvad der skete.
Hvad du skal bruge
- Et Google Sheet med kontakter (én række pr. kontakt, header-række først)
- En Brevo-konto og en API-nøgle (Settings → SMTP & API → API Keys)
- Det numeriske ID på Brevo-listen, du vil have kontakter tilføjet til
Det er det. Ingen npm, ingen Python, ingen server.
Ark-layout
Scriptet i denne guide forventer en header-række efterfulgt af én kontakt pr. række. Kolonner mappes via header-navn til Brevo-attributter:
| firstName | lastName | company | city | |
|---|---|---|---|---|
| [email protected] | Jane | Doe | Acme | Berlin |
| [email protected] | John | Smith | Globex | Paris |
email er obligatorisk og matches case-insensitivt. Alt andet sendes til Brevo som en kontaktattribut. Brugerdefinerede attributter (alt ud over standardsæt som FIRSTNAME, LASTNAME) skal findes i din Brevo-konto først, definér dem under Contacts → Settings → Contact attributes, eller via Brevo-API’et.
Åbn Apps Script-editoren
I dit Sheet: Extensions → Apps Script. En ny fane åbner med en tom Code.gs. Erstat indholdet med scriptet nedenfor.
Det fulde script
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;}Det er det hele. Gem det (⌘S / Ctrl+S), navngiv projektet noget i stil med “Brevo sync”, og hop tilbage til dit Sheet.
Første kørsel
Genindlæs Sheet’et, den nye Brevo-menu dukker op øverst.
- Klik Brevo → Configure API key, indsæt din
xkeysib-...-nøgle, klik OK. - Klik Brevo → Sync sheet to Brevo. Google vil bede om tilladelser første gang:
- “View and manage your spreadsheets”, nødvendigt for at læse rækkerne
- “Connect to an external service”, nødvendigt for at kalde api.brevo.com
- Godkend. Scriptet kører. En grøn toast i nederste højre fortæller dig, hvor mange kontakter der gik ud.
Hvis det fejler, klik Extensions → Apps Script → View → Logs for at se pr-batch-statuskoden fra Brevo. Den hyppigste fejl er en 400 på grund af en manglende brugerdefineret attribut, se troubleshooting-afsnittet nedenfor.
Kør det på en tidsplan
I Apps Script-editoren: Triggers (uret-ikonet i venstre sidebar) → Add Trigger.
- Choose function:
syncSheetToBrevo - Event source: Time-driven
- Type: Hour timer (eller Day timer for en gang-om-dagen-synk)
- Interval: hver time (eller hvad der passer)
Gem. Google kører funktionen i den kadence for evigt, ingen server, ingen cron, ingen vedligeholdelse.
Du kan også bruge From spreadsheet → On edit, hvis du vil have hver celleændring til at trigge en synk. Vær forsigtig med det, selv kosmetiske ændringer fyrer triggeren, hvilket kan ramme Apps Scripts daglige kvota hurtigt på travle ark. Den hourly time trigger er næsten altid det rigtige svar.
Apps Script-kvotaer du bør kende
Apps Script free tier har grænser, der er værd at respektere:
| Grænse | Værdi (free tier) |
|---|---|
| Total køretid pr. dag | 90 minutter |
| Enkelt eksekveringstid | 6 minutter |
UrlFetchApp-kald pr. dag | 20.000 |
UrlFetchApp payload-størrelse | 50 MB |
UrlFetchApp headers-størrelse | 8 KB |
| Triggers pr. bruger pr. script | 20 |
Til en typisk kontaktsynk (et par tusind kontakter, hourly) er du intet sted nær nogen af disse. Den eneste at holde øje med er 6-minutters enkelt eksekvering, hvis du nogensinde synker hundredtusinder af kontakter på én gang, batch dem i mindre stykker (scriptet ovenfor gør det allerede via BATCH_SIZE).
Håndtering af importen asynkront
Brevos import-endpoint er asynkront: du får et processId tilbage med det samme, og selve importen kører server-side. For de fleste sheet-synker er det fint, fyr og glem, Brevo sender en summary-e-mail, når hver batch er færdig.
Hvis du vil blokere, indtil importen virkelig er færdig, poll process status-endpointet:
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 er Apps Scripts ækvivalent til en blokerende vent. Sov ikke for længe, du har 6 minutter total pr. eksekvering.
Tilføj en notify-webhook
Et renere mønster end polling: deploy dit Apps Script som en Web App og send dets URL som notifyUrl. Brevo POSTer til den, når importen er færdig.
// 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: Deploy → New deployment → Web app, sæt “Who has access” til Anyone, kopiér den resulterende URL og send den som notifyUrl i din import-payload:
payload.notifyUrl = 'https://script.google.com/macros/s/AKfy.../exec';Nu poster Brevo resultatet tilbage til dit Sheets eget script, lukker loopet uden ekstern infrastruktur.
Troubleshooting
400 Bad Request med error: "Attribute X not found". En kolonne i dit Sheet mappes til en attribut, Brevo ikke kender. Enten omdøb Sheet-kolonnen til at matche en eksisterende attribut, eller opret attributten i Brevo (Contacts → Settings → Contact attributes).
401 Unauthorized. API-nøglen er forkert eller udløbet. Kør Configure API key igen, indsæt en frisk nøgle fra Brevos dashboard.
429 Too Many Requests. Du rammer Brevos rate limit. Import-endpointet tillader omkring 30 kald pr. minut. Hvis du batcher aggressivt, tilføj Utilities.sleep(2000) mellem batches i loopet.
Scriptet kører stille ikke på tidsplanen. Tjek Triggers i Apps Script-editoren. Hvis en trigger fejler gentagne gange, deaktiverer Google den. Klik ind i triggeren for at se fejlårsagen, normalt et permissions-problem, du kan re-autorisere.
Brevo-menuen dukkede ikke op. onOpen kører kun, når du (gen)åbner Sheet’et fra bunden. Genindlæs browser-fanen.
Permissions-pop-up bliver ved med at komme tilbage. Du har sandsynligvis redigeret scriptets scopes (tilføjet en ny Google-tjeneste). Apps Script genprompter for autorisation, hver gang de krævede tilladelser ændrer sig. Kør hvilken som helst funktion én gang fra editoren for at trigge prompten og godkende.
Hvorfor det slår Zapier og venner
Apps Script er gratis, lever inde i Googles infrastruktur og har direkte adgang til Sheets data, ingen række-for-række event-firing, ingen pr-task-pricing, ingen rate limits ud over Googles kvota (som er generøs for denne slags job). Bagsiden: du forpligter dig til at skrive og vedligeholde et lille stykke kode. For en kontaktsynk er det ca. 100 linjer og dybest set nul løbende vedligeholdelse.
Par det med en daglig trigger og et ark, dit salgsteam allerede opdaterer, og du har en kontakt-pipeline til Brevo med nul tilbagevendende arbejde.