On This Page
A contact import can return 201 while the integration is still wrong: the source ID was dropped, the wrong list was chosen, or an earlier opt-out was ignored. Those are data ownership decisions, not HTTP details.
This guide gets a Brevo request working, then shows the checks that make a small contact sync repeatable.
What the Brevo API covers
Everything lives under a single host and a single version path. The developer documentation groups the surface into four product areas:
- Messaging: transactional email, SMS, and WhatsApp, including batch sends, scheduling, and message activity.
- Marketing platform: contacts, lists, segments, and email campaigns.
- eCommerce: products, orders, and customer event tracking.
- Conversations: the chat widget and programmatic conversation management.
Those areas share one account, one contact database, and one API key. That is convenient and occasionally dangerous: a script written against a staging idea of the data is talking to the same contacts your campaigns send to.
Transactional versus marketing
The two families behave differently enough that mixing them up is the most common design error.
| Transactional | Marketing | |
|---|---|---|
| Primary endpoint | POST /v3/smtp/email | POST /v3/emailCampaigns |
| Addressing | Explicit recipients in the request | listIds or segmentIds |
| Trigger | Your application, in real time | Scheduled or sent on demand |
| Typical volume shape | Continuous, one message at a time | Bursty, one large send |
| Rate limit posture | Very high, 1,000 requests per second on the general tier | Low, campaign endpoints fall under the general cap |
If you are still deciding whether Brevo is the right platform at all, the platform overview covers that ground.
Authentication and key management
For standard API-key authentication, send the key in Brevo’s api-key header. OAuth uses a Bearer token on supported operations; do not put a standard API key in that header.
curl https://api.brevo.com/v3/account \ -H "api-key: $BREVO_API_KEY"Keys are generated in the Brevo app under account settings, in the SMTP and API section, on the API keys tab. Give each key a descriptive name tied to the system that uses it. The key value is displayed exactly once when it is generated, so if you lose it you generate a new one rather than recovering the old one.
A few practical rules:
- Issue a separate key per deployment target and per service. Revoking a compromised key should never take down three unrelated systems.
- Standard API keys are account-wide. Treat any key as full access to contacts, sending, and CRM data.
- Brevo also supports OAuth 2.0 for applications that act on behalf of other Brevo accounts, described alongside the key flow in authentication schemes.
- The MCP server used by AI assistants takes a separate token and does use a bearer header. That token is generated in the same API keys screen but is not interchangeable with a REST key.
Base URL, versioning, and your first write
The base URL is https://api.brevo.com/v3/. The version is in the path rather than in a header, and v3 is the current generation. Every path in this guide is relative to that base.
For a first contact write, use a test address you control, a real list ID, and a source ID that stays the same on every run. Create the FNAME attribute in Brevo first. This is a live contact write if you run it with a real key:
curl -X POST https://api.brevo.com/v3/contacts \ -H "api-key: $BREVO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "email": "[email protected]", "ext_id": "test-source-contact-1", "attributes": { "FNAME": "Dev" }, "listIds": [42], "updateEnabled": true, "getId": true }'Replace 42 with your test list ID. Read the contact back and check the ID, mapped name, list membership, and suppression state. A 2xx response alone does not prove the intended sync result.
The endpoints you will actually use
Contacts
POST /v3/contacts creates a contact. Its body accepts email, declared attributes, listIds, and ext_id for a stable source key. Set updateEnabled: true for an existing contact and getId: true when you need the surviving ID. Brevo documents these fields. Use the same ext_id on every run and keep a local source-to-Brevo ID mapping for reconciliation. Do not generate a new ID for each request.
Before the first write, decide which system owns each attribute and which exact list receives consented contacts. An explicit listIds value is safer than an implicit default. Never use a routine upsert to set emailBlacklisted: false. Brevo warns that changing a blocked contact’s email address removes its block, so treat email changes as a separate, reviewed operation. Read current suppression and list-unsubscribe state before adding a contact to a marketing list. A read/write race remains; a final pre-send suppression check is needed where a strict guarantee matters.
Reads go through GET /v3/contacts, which pages with limit (default 50, maximum 1000) and offset, and supports modifiedSince and createdSince in UTC. Incremental syncs should lean on modifiedSince rather than walking the full list. Note that the filter parameter supports only an equality operator, so anything more expressive belongs in a segment.
For bulk loading, POST /v3/contacts/import accepts fileUrl, fileBody, or jsonBody, targets listIds, and runs asynchronously, returning a processId. Brevo documents a 10 MB maximum body and recommends staying near 8 MB because parsing inflates the payload. Supply notifyUrl so you learn the outcome instead of polling.
Transactional email
POST /v3/smtp/email is the workhorse. Beyond sender, to, subject, and htmlContent, the fields worth knowing are:
templateIdwithparams, which replaces inline content with a Brevo template and its variable substitutions. Individual version params are capped at 100 KB, cumulative params at 1000 KB.messageVersions, which sends personalised variants in one call, with up to 99 recipients per version.tagscan help correlate delivery events with the application workflow that produced them.scheduledAtplusbatchId, for future sends you may want to cancel as a group.headers, in Title-Case, for custom SMTP headers.
A single request accepts at most 2,000 recipients. For the difference between this endpoint and campaign sending, the transactional email guide has the messaging-strategy view.
Email campaigns
POST /v3/emailCampaigns requires name and sender, plus exactly one content source: htmlContent (minimum 10 characters, under 1 MB), htmlUrl, or templateId. Audience goes in recipients as listIds or segmentIds, and scheduledAt uses the YYYY-MM-DDTHH:mm:ss.SSSZ UTC format. Companion routes cover sending immediately, sending a test, updating status, and pulling the campaign report.
Companies, deals, and objects
Brevo’s CRM has two overlapping write paths, and choosing correctly matters.
The CRM routes are POST /v3/companies, PATCH /v3/companies/{id}, DELETE /v3/companies/{id}, and the equivalent set for deals. These are synchronous. A PATCH returns 204 once the change is applied.
The objects API is the bulk path: POST /v3/objects/{object_type}/batch/upsert takes up to 1000 records and 1 MB per request, up to 500 attributes per record, and up to 10 association records per object type per record. It returns 202 with a processId, meaning accepted rather than applied.
curl -X POST https://api.brevo.com/v3/objects/company/batch/upsert \ -H "api-key: $BREVO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "records": [ { "identifiers": { "id": 12345 }, "attributes": { "domain": "acme.example", "industry": "retail" } } ] }'The Brevo CRM guide covers the object model from the operator side.
Official SDKs
Brevo maintains clients under the getbrevo GitHub organisation:
| Language | Repository |
|---|---|
| Node.js | github.com/getbrevo/brevo-node |
| Python | github.com/getbrevo/brevo-python |
| PHP | github.com/getbrevo/brevo-php |
| Java | github.com/getbrevo/brevo-java |
| C# | github.com/getbrevo/brevo-csharp |
| Go | github.com/getbrevo/brevo-go |
| Ruby | github.com/getbrevo/brevo-ruby |
The Node client installs as @getbrevo/brevo:
npm install @getbrevo/brevoimport { BrevoClient } from "@getbrevo/brevo";
// Reconcile uncertain send outcomes before retrying.const brevo = new BrevoClient({ apiKey: process.env.BREVO_API_KEY, maxRetries: 0 });
const result = await brevo.transactionalEmails.sendTransacEmail({ subject: "Order confirmed", htmlContent: "<html><body><p>Thanks for your order.</p></body></html>", tags: ["order-confirmation"],});
console.log("Message ID:", result.messageId);The Python client installs with pip install brevo-python. If you would rather not carry an SDK dependency for two endpoints, the raw HTTP surface is small enough to call directly, which also keeps you insulated from SDK version churn:
import osimport requests
BASE = "https://api.brevo.com/v3"HEADERS = {"api-key": os.environ["BREVO_API_KEY"]}
def upsert_consented_contact(source_id, email, first_name, list_id, consent): if not consent: raise ValueError("No marketing consent in the source") if not source_id or not isinstance(list_id, int): raise ValueError("Stable source ID and numeric list ID required") # Configure FNAME in Brevo before using this explicit field mapping. response = requests.post( f"{BASE}/contacts", headers=HEADERS, json={ "email": email, "ext_id": source_id, "attributes": {"FNAME": first_name}, "listIds": [list_id], "updateEnabled": True, "getId": True, }, timeout=30, ) response.raise_for_status() return response.json().get("id") if response.content else NoneThis example assumes the caller has already checked Brevo suppression and consent records. It never requests an unblock. A successful update can have an empty 204 response; None then means read the contact to obtain its ID. Test it with a mock before using an account; the code above performs a live write when called with a real key. Identity collisions can return a 4xx depending on the identifiers and endpoint. Inspect the response and reconcile the IDs; do not treat every contact conflict as HTTP 409.
There is also an MCP server at https://mcp.brevo.com/v1/brevo/mcp for AI assistants, authenticated with a bearer token generated in the same settings screen. It is useful for exploration and account questions, not for production data paths.
Webhooks
Webhooks are how you learn what happened after a send. POST /v3/webhooks creates one, with url, events, type, and optionally channel (email or sms), batched, custom headers, and an auth object.
There are three webhook types with distinct event vocabularies:
- Transactional:
sent,request,delivered,hardBounce,softBounce,blocked,spam,invalid,deferred,click,opened,uniqueOpened,unsubscribed. - Marketing:
spam,opened,click,hardBounce,softBounce,unsubscribed,listAddition,delivered,contactUpdated,contactDeleted. - Inbound:
inboundEmailProcessed, which requires adomain.
curl -X POST https://api.brevo.com/v3/webhooks \ -H "api-key: $BREVO_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://yourapp.example/hooks/brevo", "type": "transactional", "events": ["delivered", "hardBounce", "spam", "unsubscribed"], "description": "Deliverability signals" }'For contact changes, register a marketing webhook with contactUpdated; Brevo’s marketing event payload names it contact_updated. Authenticate the receiver using the webhook auth bearer token or custom headers shown in Brevo’s secure webhook guide. Validate the token before parsing or applying an event. Keep it out of logs.
Store a deduplication key from the event type, contact identifier, and event timestamp, with a retention window long enough for retries. A timestamp alone is not unique, and delivery can be repeated or arrive out of order. After an event, fetch the current contact and reconcile from your authoritative source. Define a replay window and reject stale events after recovery; run a periodic incremental check to cover missed deliveries. A webhook signals a change. It does not by itself make a two-way sync. The connector guide covers the broader sync decision.
Rate limits and error handling
Brevo’s rate limits are per endpoint and per plan tier, and the spread between endpoints is enormous.
| Endpoint | General (every plan) | Advanced (Professional and Enterprise) |
|---|---|---|
POST /v3/smtp/email | 1,000 RPS | 2,000 RPS |
POST /v3/transactionalSMS/send | 150 RPS | 200 RPS |
/v3/contacts/... | 10 RPS, 36,000 RPH | 20 RPS, 72,000 RPH |
POST /v3/events | 10 RPS, 36,000 RPH | 20 RPS, 72,000 RPH |
GET /v3/smtp/emails | 2 RPS, 7,200 RPH | 3 RPS, 10,800 RPH |
| Endpoints without their own limit | 100 RPH | 200 RPH |
That last row is the one that hurts. Sending has a high request limit, while campaign management, CRM reads, and most administrative calls fall under a 100 requests per hour budget on the general tier. A naive backfill that reads a company record before each write will exhaust an hour of quota in under two minutes.
Brevo documents x-sib-ratelimit-limit, x-sib-ratelimit-remaining, and x-sib-ratelimit-reset on rate-limited endpoints. Read them on success, not only on failure. A 429 should pause until reset, then retry within a fixed attempt and delay budget. If the required wait exceeds that budget, surface the 429 for a later scheduled attempt. The reset value is a duration in the window’s unit, typically seconds; it is not an epoch timestamp.
async function callBrevo(path, init) { for (let attempt = 0; attempt < 3; attempt++) { const response = await fetch(`https://api.brevo.com/v3${path}`, { ...init, headers: { "api-key": process.env.BREVO_API_KEY, ...init.headers }, }); if (response.status !== 429 || attempt === 2) return response; const raw = Number(response.headers.get("x-sib-ratelimit-reset")); const seconds = Number.isFinite(raw) && raw > 0 ? raw : 2 ** attempt; const delayMs = (seconds + 2 ** attempt) * 1000; if (delayMs > 60_000) return response; await new Promise(resolve => setTimeout(resolve, delayMs)); }}This helper is for requests you have checked are safe to repeat. For ambiguous send failures, reconcile with your own message identifier before issuing another send. Log the status and rate headers without logging the API key or contact data.
Do not blindly retry a 4xx. Brevo’s contact-create reference lists 400 and 425, and an identifier conflict may appear as a 4xx rather than a specific 409. For a 5xx after a write, check whether it landed before repeating it.
Which status came back?
-
429
Wait within a retry budget
Use the reset duration and stop after a fixed number of tries.
-
5xx after a write
Check whether it landed
The response can fail after the server applied the change.
-
400
Fix the request
It usually means the request is wrong rather than early.
-
Contact identifier conflict
Read and reconcile IDs
The contact API does not promise a particular conflict status.
Testing without sending mail
Add the header X-Sib-Sandbox with the value drop to a transactional send. Brevo validates the request, returns 201 with a messageId, delivers nothing, and writes no email log.
curl -X POST https://api.brevo.com/v3/smtp/email \ -H "api-key: $BREVO_API_KEY" \ -H "X-Sib-Sandbox: drop" \ -H "Content-Type: application/json" \ -d '{ "sender": { "email": "[email protected]" }, "to": [{ "email": "[email protected]" }], "subject": "Sandbox", "htmlContent": "<p>hi</p>" }'Understand what this does and does not prove. Sandbox mode validates request format only. It says nothing about sender authentication, template rendering, or deliverability. Keep a separate Brevo account for integration testing of anything that touches contacts or CRM data, because sandbox mode covers sending and not the rest of the API.
Before you run a contact sync
- Choose one stable source ID and map it to Brevo
ext_id; retain the returned Brevo ID. - Pre-create and verify every attribute you send. Brevo can ignore unknown or wrong-typed attributes, so read back one test contact.
- Use an explicit list ID and an authoritative consent record. Skip existing suppression and unsubscribe states.
- Record accepted, applied, skipped, and failed counts separately. A 202 from a bulk or object endpoint means accepted for later processing.
- Use marketing
contactUpdatedwebhooks as change signals, then reconcile current state. Authenticate, deduplicate, and bound replay. - Observe endpoint rate headers and stop after a small retry budget.
Tajo focuses on keeping commerce data in sync with Brevo. If you are building this layer yourself, the Brevo connector guide compares connection paths and failure handling.
Frequently Asked Questions
What is the base URL of the Brevo API?
All Brevo REST calls go to https://api.brevo.com/v3/. The version is part of the path, and v3 is the current generation, so an endpoint such as the transactional send is the full path https://api.brevo.com/v3/smtp/email.
How do you authenticate with the Brevo API?
Send your key in an HTTP header named api-key. Brevo does not use an Authorization or Bearer header for standard API keys. Keys are generated under Account settings, SMTP and API, API keys, and the value is displayed only once.
What are the Brevo API rate limits?
Limits are per endpoint and per rate limit tier. On the general tier, which every plan gets, the transactional send endpoint allows 1,000 requests per second, contacts endpoints allow 10 per second, and endpoints without their own limit are capped at 100 requests per hour. Professional and Enterprise plans can use the advanced tier, and Enterprise the extended tier. Exceeding a limit returns 429.
Does Brevo have official SDKs?
Yes. Brevo publishes clients for Node.js, Python, PHP, Java, C#, Go, and Ruby under the getbrevo GitHub organisation. The Node package is @getbrevo/brevo on npm and the Python package is brevo-python on PyPI.
How do I test the Brevo API without sending real email?
Add the header X-Sib-Sandbox with the value drop to a transactional send. Brevo validates the request, returns 201 with a messageId, sends nothing, and writes no email log. It only checks request format, not deliverability.
What webhook events does Brevo support?
Transactional webhooks cover sent, request, delivered, hardBounce, softBounce, blocked, spam, invalid, deferred, click, opened, uniqueOpened, and unsubscribed. Marketing webhooks add listAddition, contactUpdated, and contactDeleted. Inbound webhook creation documents inboundEmailProcessed.
How should a contact upsert handle an existing opt-out?
Use a stable source identifier, map only declared attributes, and omit emailBlacklisted: false from routine upserts. Check the existing contact and your consent source before adding it to a marketing list; do not change a blocked email address as a side effect.
Does a webhook make a two-way sync?
No. Authenticate the receiver, deduplicate deliveries, and reconcile missed or out-of-order events from an authoritative source. A webhook is a change signal, not proof that both systems now agree.