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 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.
| 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 standard plans | 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
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.
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):
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:
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.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.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";
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>", 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"], "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 responseThere 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:
inboundEmailProcessedandreply, which additionally require 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" }'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.
| Endpoint | Standard | 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 | higher on Enterprise |
GET /v3/smtp/emails | 2 RPS, 7,200 RPH | 3 RPS, 10,800 RPH |
| Everything else | 100 RPH | 200 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.
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 anapi-keyheader 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.