How to Import CSV or Excel Contacts to Brevo with a Script (Python, Node.js, cURL)
Bulk-import contacts from a CSV or Excel file into Brevo using the import-contacts API. Includes ready-to-run Python and Node.js scripts, a cURL one-liner, error handling, and tips for files larger than 10 MB.
If you’ve ever needed to push a few thousand contacts from a spreadsheet into Brevo, the manual UI path gets painful fast: pick the file, map columns, pick a list, wait, repeat. A script does the same job in seconds and — more importantly — you can run it again on a schedule, after a database export, or whenever your CRM kicks out a fresh CSV.
This guide covers the API endpoint Brevo gives you for exactly this, with full working scripts in Python, Node.js, and a cURL one-liner. Everything in this guide hits POST /v3/contacts/import.
The endpoint at a glance
POST https://api.brevo.com/v3/contacts/importContent-Type: application/jsonapi-key: YOUR_API_KEY
{ "listIds": [42], "updateExistingContacts": true, "emailBlacklist": false, "smsBlacklist": false}The response comes back fast:
{ "processId": 78 }That’s a 202 Accepted — Brevo accepted the import and is processing it in the background. The processId is your tracking handle if you want to poll for completion or set up a notifyUrl webhook.
A few things worth noting before you write code:
- The body can be CSV (
fileBody), JSON (jsonBody), or a remote URL (fileUrl). Pick one. The CSV form uses semicolons (;) to separate columns, not commas — that’s a common gotcha. fileBodyandjsonBodyare capped at 10 MB. Brevo recommends staying around 8 MB to be safe. For anything larger, upload the file to S3, GCS, or any HTTPS host and passfileUrlinstead.- Custom attributes that don’t exist in your account get silently ignored. Create them in Brevo’s UI (or via the Attributes API) before importing rows that use them, otherwise the data just disappears.
updateExistingContactsdefaults totrue. If you set it tofalse, Brevo skips contacts whose email already exists — useful for “add new only” jobs.emptyContactsAttributescontrols whether empty cells in your CSV erase existing values. Defaultfalse(empty cells are ignored). Set totrueif your CSV is the source of truth and you want blanks to clear out stale data.
Get an API key
Log in to Brevo → Settings → SMTP & API → API Keys → create a new key. It looks like xkeysib-.... You’ll pass it as the api-key HTTP header on every request. Treat it like a password — it has full read/write on your account.
A clean pattern: put it in an environment variable so it never lands in your source tree.
export BREVO_API_KEY="xkeysib-..."Python: read a CSV, push it to Brevo
This is the simplest possible script: read a local CSV with the standard library, send the body straight to Brevo. No third-party SDK needed beyond requests.
import csvimport ioimport osimport sysimport requests
API_KEY = os.environ["BREVO_API_KEY"]LIST_ID = 42 # the Brevo list to add contacts toINPUT_FILE = sys.argv[1] if len(sys.argv) > 1 else "contacts.csv"
# Brevo wants semicolon-separated CSV. If your file uses commas (most do),# convert it on the fly so you don't have to edit the source spreadsheet.def to_brevo_csv(path: str) -> str: with open(path, newline="", encoding="utf-8") as f: reader = csv.reader(f) # default: comma-separated out = io.StringIO() writer = csv.writer(out, delimiter=";") for row in reader: writer.writerow(row) return out.getvalue()
body = { "fileBody": to_brevo_csv(INPUT_FILE), "listIds": [LIST_ID], "updateExistingContacts": True, "emptyContactsAttributes": False, "emailBlacklist": False, "smsBlacklist": False,}
resp = requests.post( "https://api.brevo.com/v3/contacts/import", json=body, headers={"api-key": API_KEY, "Content-Type": "application/json"}, timeout=60,)
if resp.status_code != 202: print(f"Import failed: {resp.status_code} {resp.text}") sys.exit(1)
process_id = resp.json()["processId"]print(f"Import accepted. Process ID: {process_id}")Run it:
python import_csv_to_brevo.py contacts.csvYour CSV’s first row is the header. Column names map to Brevo attributes by uppercase, exact match: EMAIL, FIRSTNAME, LASTNAME, plus any custom attribute you’ve defined. EMAIL is mandatory.
EMAIL,FIRSTNAME,LASTNAME,COMPANY,CITYThat’s the whole flow. The script returns within a second; the actual import runs server-side and takes anywhere from a few seconds to a few minutes depending on volume.
Excel files: three viable paths
Brevo’s import endpoint doesn’t read .xlsx directly, so you have three real options depending on where the work needs to live:
- Run a VBA macro inside the workbook — one-click sync from Excel itself, no external script. This is the right answer when the file lives on a desktop and your team wants a button on the sheet. Full code in the Excel-to-Brevo VBA macro guide.
- Office Scripts + Power Automate — if the workbook is in OneDrive/SharePoint and you want unattended scheduled sync. Also covered in the Excel guide.
- Convert
.xlsxto CSV from a script — what the rest of this section covers. Best if you’re already running Python or Node on a server and just need to pull a workbook in once a day.
For option 3, pandas makes it a one-liner:
# pip install pandas openpyxlimport pandas as pd
def xlsx_to_brevo_csv(path: str, sheet_name: str | int = 0) -> str: df = pd.read_excel(path, sheet_name=sheet_name) return df.to_csv(sep=";", index=False)
body["fileBody"] = xlsx_to_brevo_csv("contacts.xlsx")If you don’t want pandas as a dependency, openpyxl alone works:
from openpyxl import load_workbookimport csv, io
def xlsx_to_brevo_csv(path: str) -> str: wb = load_workbook(path, read_only=True) ws = wb.active out = io.StringIO() writer = csv.writer(out, delimiter=";") for row in ws.iter_rows(values_only=True): writer.writerow(["" if v is None else v for v in row]) return out.getvalue()Node.js: same job, official SDK
Brevo publishes @getbrevo/brevo for Node. It handles auth, retries, and the typed request shape:
// npm install @getbrevo/brevoimport fs from 'node:fs/promises';import { BrevoClient } from '@getbrevo/brevo';
const API_KEY = process.env.BREVO_API_KEY;const LIST_ID = 42;const INPUT_FILE = process.argv[2] ?? 'contacts.csv';
const client = new BrevoClient({ apiKey: API_KEY });
// Read the CSV and convert commas to semicolons if neededconst raw = await fs.readFile(INPUT_FILE, 'utf-8');const csv = raw.includes(';') ? raw : raw.replace(/,/g, ';');
const result = await client.contacts.importContacts({ fileBody: csv, listIds: [LIST_ID], updateExistingContacts: true, emptyContactsAttributes: false,});
console.log(`Import accepted. Process ID: ${result.processId}`);Or, if you don’t want the SDK, plain fetch works the same way:
const resp = await fetch('https://api.brevo.com/v3/contacts/import', { method: 'POST', headers: { 'api-key': process.env.BREVO_API_KEY, 'Content-Type': 'application/json', }, body: JSON.stringify({ fileBody: csv, listIds: [42], updateExistingContacts: true, }),});
if (resp.status !== 202) { throw new Error(`Import failed: ${resp.status} ${await resp.text()}`);}
const { processId } = await resp.json();console.log(`Process ID: ${processId}`);cURL: a one-liner for ad-hoc imports
When you just want to test the endpoint or shove a small file in by hand:
# Convert commas to semicolons, then send the file inlinecsv=$(sed 's/,/;/g' contacts.csv | jq -Rs .)
curl -X POST https://api.brevo.com/v3/contacts/import \ -H "api-key: $BREVO_API_KEY" \ -H "Content-Type: application/json" \ -d "{\"fileBody\": $csv, \"listIds\": [42], \"updateExistingContacts\": true}"jq -Rs . slurps the whole file and JSON-escapes it — saves you from manually escaping newlines and quotes.
Files larger than 10 MB: use fileUrl
fileBody is capped at 10 MB. If your contact dump is bigger, host the file at a URL Brevo can fetch and pass that:
body = { "fileUrl": "https://files.example.com/contacts/2026-04-30.csv", "listIds": [42], "updateExistingContacts": True,}Anything reachable via a public HTTPS URL works — pre-signed S3 URLs, GCS, your own static host, even a GitHub raw URL for one-off imports. Brevo fetches the file from your URL, then runs the import. Accepted formats: .csv, .txt, .json.
Send JSON instead of CSV
If you’re already pulling contacts from a database, you don’t need to round-trip through CSV — send them as JSON directly:
body = { "jsonBody": [ { "attributes": { "FIRSTNAME": "Jane", "LASTNAME": "Doe", "COMPANY": "Acme", }, }, { "attributes": { "FIRSTNAME": "John", "LASTNAME": "Smith", "COMPANY": "Globex", }, }, ], "listIds": [42],}Same 10 MB cap. Same async behavior.
Polling for completion
The import is asynchronous — processId is your handle to track it. There’s a separate endpoint to check process status:
def wait_for_import(process_id: int, timeout_s: int = 600) -> dict: import time deadline = time.time() + timeout_s while time.time() < deadline: r = requests.get( f"https://api.brevo.com/v3/processes/{process_id}", headers={"api-key": API_KEY}, timeout=30, ) r.raise_for_status() status = r.json() if status["status"] in ("completed", "failed"): return status time.sleep(5) raise TimeoutError(f"Import {process_id} did not finish in {timeout_s}s")
result = wait_for_import(process_id)print(f"Import {result['status']}: {result}")For long-running jobs, the cleaner pattern is notifyUrl: pass an HTTPS endpoint Brevo will POST to when the import finishes, and skip the polling.
body["notifyUrl"] = "https://your-app.example.com/webhooks/brevo-import"Common errors and how to fix them
400 Bad Request with no obvious cause — Almost always semicolons vs commas in the CSV. Brevo’s import expects ;, not ,. Double-check fileBody after your conversion step.
Custom attribute values disappear — The attribute doesn’t exist in your account. Create it under Contacts → Settings → Contact attributes before importing, or use the Attributes API to create it as part of your script.
401 Unauthorized — Wrong header name. It’s api-key (lowercase, hyphen), not Authorization or X-API-Key.
Import “succeeded” but contacts didn’t appear in the list — Check that listIds contains the right list. Also: if updateExistingContacts is false and the contacts already exist, Brevo silently skips them rather than re-adding them to the list.
Some rows imported, some didn’t — Brevo emails you a per-row error report after the import finishes (unless you set disableNotification: true). The report tells you which rows had bad emails, missing required fields, or formatting issues.
When to script vs. when to use the UI
The UI is fine for one-off imports under a thousand rows. A script wins as soon as:
- You’re importing more than once (e.g. weekly export from your CRM)
- The source data needs cleaning before it lands in Brevo (deduplication, formatting phone numbers, splitting full names into first/last)
- You want it to run on a schedule without anyone clicking buttons
- The file is bigger than the UI’s comfort zone
Wrap the script above in a cron job or a GitHub Action and you’ve got automated contact sync. The next post in this series shows how to do the same thing directly from a Google Sheet using Apps Script — no server required.