Brevo API: A Practical Developer Guide

Brevo API guide for developers: authentication, base URL, contacts, transactional email, campaigns, CRM objects, webhooks, rate limits, and real-world limits.

Brevo API
Brevo API?

Brevo exposes one REST API that spans transactional messaging, marketing campaigns, contact data, and CRM records. Getting the first request to return 201 takes about two minutes. Getting a production integration that does not silently lose data takes considerably longer, because several of the constraints that matter most are either undocumented or contradict what the API reports about itself.

This guide covers both halves: the endpoints, SDKs, and authentication you need on day one, and the platform limits you need to design around before you ship.

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.

TransactionalMarketing
Primary endpointPOST /v3/smtp/emailPOST /v3/emailCampaigns
AddressingExplicit recipients in the requestlistIds or segmentIds
TriggerYour application, in real timeScheduled or sent on demand
Typical volume shapeContinuous, one message at a timeBursty, one large send
Rate limit postureVery high, 1,000 requests per second on standard plansLow, 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

Brevo uses a plain API key in a custom header. The header is named api-key, not Authorization, and there is no Bearer prefix. This trips up almost everyone who has used another messaging API first.

Terminal window
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.

A first write is more informative than a first read, because it exercises the parts of the account that are usually misconfigured (verified senders, in particular):

Terminal window
curl -X POST https://api.brevo.com/v3/smtp/email \
-H "api-key: $BREVO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"sender": { "name": "Ops", "email": "[email protected]" },
"to": [{ "email": "[email protected]", "name": "Dev" }],
"subject": "First transactional send",
"htmlContent": "<html><body><p>It works.</p></body></html>",
"tags": ["smoke-test"]
}'

A successful send returns 201 with a messageId. A scheduled send returns 202.

The endpoints you will actually use

Contacts

POST /v3/contacts creates a contact. The body takes email, an attributes map for custom fields, listIds, ext_id for your own external key, and the two flags that matter most in practice: updateEnabled, which turns the call into an upsert, and getId, which makes the response return the contact id.

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:

  • templateId with params, 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.
  • tags, which you should always set. Tags come back on webhook events, and they are the only cheap way to correlate a delivery event with the code path that produced it.
  • scheduledAt plus batchId, 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.

Terminal window
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:

LanguageRepository
Node.jsgithub.com/getbrevo/brevo-node
Pythongithub.com/getbrevo/brevo-python
PHPgithub.com/getbrevo/brevo-php
Javagithub.com/getbrevo/brevo-java
C#github.com/getbrevo/brevo-csharp
Gogithub.com/getbrevo/brevo-go
Rubygithub.com/getbrevo/brevo-ruby

The Node client installs as @getbrevo/brevo:

Terminal window
npm install @getbrevo/brevo
import { BrevoClient } from "@getbrevo/brevo";
const brevo = new BrevoClient({ apiKey: process.env.BREVO_API_KEY });
const result = await brevo.transactionalEmails.sendTransacEmail({
subject: "Order confirmed",
htmlContent: "<html><body><p>Thanks for your order.</p></body></html>",
sender: { name: "Acme", email: "[email protected]" },
to: [{ email: "[email protected]", name: "Customer" }],
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 os
import requests
BASE = "https://api.brevo.com/v3"
HEADERS = {
"api-key": os.environ["BREVO_API_KEY"],
"Content-Type": "application/json",
}
def upsert_contact(email, attributes, list_ids):
response = requests.post(
f"{BASE}/contacts",
headers=HEADERS,
json={
"email": email,
"attributes": attributes,
"listIds": list_ids,
"updateEnabled": True,
},
timeout=30,
)
response.raise_for_status()
return response

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 and reply, which additionally require a domain.
Terminal window
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"
}'

Three things to get right. First, an account can hold at most 40 webhooks across all types, so route by event inside your handler rather than registering one endpoint per event. Second, use the batched flag when you expect volume, since one request carrying many events is far cheaper to process than many requests. Third, protect the receiver: Brevo publishes its sending IP ranges, and restricting your endpoint to those ranges is the documented approach. Add your own shared secret through the headers field as a second layer.

Handlers must be idempotent. Treat the message id plus event type plus timestamp as the deduplication key.

Rate limits and error handling

Brevo’s rate limits are per endpoint and per plan tier, and the spread between endpoints is enormous.

EndpointStandardProfessional and Enterprise
POST /v3/smtp/email1,000 RPS2,000 RPS
POST /v3/transactionalSMS/send150 RPS200 RPS
/v3/contacts/...10 RPS, 36,000 RPH20 RPS, 72,000 RPH
POST /v3/events10 RPS, 36,000 RPHhigher on Enterprise
GET /v3/smtp/emails2 RPS, 7,200 RPH3 RPS, 10,800 RPH
Everything else100 RPH200 RPH

That last row is the one that hurts. Sending is effectively unmetered, while campaign management, CRM reads, and most administrative calls share a 100 requests per hour budget on standard plans. A naive backfill that reads a company record before each write will exhaust an hour of quota in under two minutes.

Every response carries x-sib-ratelimit-limit, x-sib-ratelimit-remaining, and x-sib-ratelimit-reset. Read them on success, not only on failure. Exceeding a limit returns 429, and the correct response is to wait for the interval in the reset header and then apply exponential backoff with jitter.

async function callBrevo(path, init, attempt = 0) {
const response = await fetch(`https://api.brevo.com/v3${path}`, {
...init,
headers: { "api-key": process.env.BREVO_API_KEY, "Content-Type": "application/json", ...init.headers },
});
if (response.status === 429 && attempt < 5) {
const reset = Number(response.headers.get("x-sib-ratelimit-reset") || 1);
const backoff = Math.pow(2, attempt) * 250 + Math.random() * 250;
await new Promise((r) => setTimeout(r, reset * 1000 + backoff));
return callBrevo(path, init, attempt + 1);
}
return response;
}

Retry 429 and 5xx. Never retry 400 or 409 blindly, because both usually mean the request is wrong rather than early, and a 409 in particular needs a different action rather than a repeat.

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.

Terminal window
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.

Limits that shape your integration design

These are the constraints that only appear once an integration runs against a real account at volume. Several contradict what the API says about itself. None of them are negotiable, so the only sensible response is to design around them.

Companies require a domain, and only one company per domain

GET /v3/crm/attributes/companies reports every attribute as not required, and the create-a-company reference lists only name as mandatory. In practice, POST /v3/companies without a non-empty domain attribute returns 400 with a message about missing mandatory default attributes. An empty string fails the same way as omitting it.

Worse, domain uniqueness is enforced. A second company on a domain already in use returns 409. For B2B commerce this is structural: subsidiaries that share one buyer email domain cannot all exist as separate companies in Brevo. Syncing a contact is also enough to make a company appear on that contact’s email domain, so a create can collide with a company nobody explicitly created. The right handler adopts the existing company on 409 instead of failing or retrying.

Undeclared attributes are discarded silently

This is the most dangerous behaviour in the platform, and Brevo documents it plainly: if an attribute appears in a request but was not previously defined in the object schema, nothing happens. No error, no attribute creation, no warning.

A 2xx response is therefore not evidence that your data landed. Read the schema before writing, drop anything undeclared in your own client, and refuse to run a sync whose attributes do not exist rather than writing half a record for a month before anyone notices.

Attribute filters are accepted and ignored

GET /v3/companies?filters[attributes.domain]=... returns 200 and ignores the filter. Two entirely different filters return the same records. There is no working way to look a company up by attribute through that route.

Combined with the fact that the unfiltered list times out with 504 on large accounts at any page size, an existing company can be genuinely unfindable through the documented path. The workaround is to scan GET /v3/objects/company/records with sort=desc, which is fast, paginated, and returns attributes, bounded to a sensible number of pages. A company that just triggered a 409 was almost always created moments earlier, so newest-first scanning finds it quickly.

One million records per object type, and no bulk delete

POST /v3/objects/{type}/batch/upsert returns 400 once an object type holds one million records. It blocks updates as well as creates: addressing an existing record by its own numeric id fails identically. The entire object write path closes at once.

Getting back under the ceiling is slow, because POST /v3/objects/{type}/batch/delete returns 403 for Brevo standard object types such as company. The only route is DELETE /v3/companies/{id}, one record per call at roughly 156 ms. Clearing 124,000 records that way took hours with 20 parallel workers. Monitor the record count on a schedule rather than discovering the ceiling through a failed sync, and route high-volume updates through PATCH /v3/companies/{id}, which has no such limit.

ext_id is Brevo’s id, not yours

On object records, identifiers.ext_id holds Brevo’s own CRM company id, a Mongo-style string. It is not a free external key. Keying an upsert on ext_id set to your platform’s identifier creates duplicates instead of matching. Your external id belongs in a declared attribute of its own.

Object upserts are asynchronous, CRM writes are not

batch/upsert returns 202 and a processId, then applies later. A non-existent id fails asynchronously and still returns 202 to your caller. PATCH /v3/companies/{id} returns 204 and is applied synchronously. If your sync reports success, only the synchronous path earns the word without a follow-up read.

A short integration checklist

  • Separate API keys per service and per environment, rotated on staff changes.
  • All writes go through one client that reads rate limit headers and backs off on 429.
  • Attribute schema is verified at startup, and the sync refuses to run if its attributes are missing.
  • 409 on company create means adopt, not retry.
  • Bulk paths use the object API for throughput and CRM routes for anything that must be confirmed.
  • Webhooks are idempotent, batched, IP-restricted, and carry a shared secret header.
  • Incremental contact syncs use modifiedSince, not full-list walks.

Building and maintaining this layer is real engineering work: schema verification, backoff, adoption logic, reconciliation. Tajo exists to absorb it, keeping Shopify and commerce data in sync with Brevo contacts, companies, and events without anyone hand-writing the retry and dedupe logic. If you are wiring it yourself instead, the Brevo integration guide walks through the data model choices that come before the code.

Key takeaways

  • The API is one REST surface at https://api.brevo.com/v3/, authenticated with an api-key header rather than a bearer token.
  • Rate limits are wildly uneven: sending is effectively unmetered, while most other endpoints share 100 requests per hour on standard plans.
  • Official SDKs exist for seven languages, but the HTTP surface is simple enough to call directly when you only need a few endpoints.
  • Sandbox mode validates request format only, so keep a separate account for testing anything beyond sends.
  • A 2xx response does not prove a write applied. Undeclared attributes are dropped silently, and object upserts are asynchronous.
  • Design around the fixed limits: one company per domain, one million records per object type, no bulk delete for standard objects, and attribute filters that quietly do nothing.

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 plan tier. On standard accounts the transactional send endpoint allows 1,000 requests per second, contacts endpoints allow 10 per second, and every other endpoint is capped at 100 requests per hour. Professional and Enterprise plans get higher tiers. 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 webhooks cover inboundEmailProcessed and reply. An account can hold up to 40 webhooks in total.
Can I create two companies with the same domain in Brevo?
No. Brevo enforces domain uniqueness on company records and returns 409 with a domain uniqueness error if you try. The correct integration behaviour is to adopt the existing company rather than retry the create.
Why did my Brevo API write return success but change nothing?
Object and CRM writes silently discard attributes that are not declared in the object schema. Brevo documents this behaviour explicitly: no error is raised and no attribute is created. Read the schema first and verify the write rather than trusting a 2xx status.
Is there a bulk delete in the Brevo API?
Not for Brevo standard object types such as company. The batch delete route returns 403 for them, so cleanup runs one record per call through DELETE /v3/companies/{id}. Plan bulk cleanup in hours, not minutes.

Request early access

Share your first name and an email address or phone number. We will follow up with Tajo access details.

automatic detection
Get Brevo