Migrate from Brevo
This page maps Brevo's transactional send payload, blocklists, and webhooks to Bird. Follow the main migration guide in order, and use these mappings for steps 1, 3, and 4.
Brevo keeps suppressions in two unrelated places, one for transactional mail and one for marketing. Read Export suppressions before you plan step 3: exporting one and not the other is the mistake this migration invites.
Hand this to your agent
Paste this into Claude Code, Cursor, or Codex. The agent works through this page against your own repository, using whichever Bird surface it already has: the MCP server if one is connected, the CLI if it is installed and signed in.
Code example
I am moving an email integration from Brevo to Bird. Route through it with me.
1. Check what you already have before setting anything up. If Bird's MCP server is connected, use its tools. If the Bird CLI is installed and signed in, use that. Either one is enough, and every step below is an action you take with whichever you have. Only if neither is present, follow https://bird.com/docs/ai/set-up-your-agent.md to set one up and sign me in. Every Bird docs page serves Markdown at its own URL with `.md` appended, so fetch that rather than the HTML.
2. Read https://bird.com/docs/guides/email/migrate/brevo.md for the payload, suppression and webhook mapping, and https://bird.com/docs/guides/email/migrate.md for the order the steps go in.
3. Find and list my Brevo usage in this repository before you change anything: calls to /v3/smtp/email and any SDK wrappers around them, whether I send with templateId or with htmlContent, the webhook handler and the URL it is registered at, and every domain I send from. Tell me the list before you edit anything.
4. Register each of those sending domains with Bird and give me the DNS records to publish, following https://bird.com/docs/guides/email/sending-domains.md. Leave every DNS record Brevo uses exactly as it is: Bird's records are published alongside them and both providers authenticate side by side until I switch traffic. Publishing DNS affects mail for the whole domain, so show me the records and let me publish them.
5. Export my suppressions and import them into Bird before any production traffic goes through Bird, so my first sends do not reach addresses that already bounced or complained. Brevo holds these in two separate places and I need both: GET /v3/smtp/blockedContacts for transactional blocks and unsubscribes, and the marketing blocklist, which lives on the contact records as emailBlacklisted rather than on a suppression endpoint. Page through both. The Bird import takes one address per request and is idempotent, so a partial re-run is safe. https://bird.com/docs/guides/email/suppressions.md has the reason taxonomy.
6. Port the send call and the webhook handler using the mapping tables on the provider page. Brevo's webhook security page documents credentials I configure on the endpoint rather than a payload signature, so check what my endpoint actually relies on today: if it is a username and password in the webhook URL, take them out and tell me to rotate that pair rather than reusing it, because a credential that has lived in a URL should be treated as exposed. Bird signs every delivery instead. https://bird.com/docs/guides/webhooks.md and https://bird.com/docs/guides/email/events.md.
7. Run my whole integration against Bird's mail sandbox before any production traffic, following https://bird.com/docs/guides/email/testing-sandbox.md. Sandbox sends run the real pipeline without reaching an inbox or touching my sending reputation.
8. Stop and ask me wherever a step needs a decision. Do not point production traffic at Bird, and do not retire the Brevo path, until I have seen the sandbox results and told you to go ahead. Finish by telling me what is left that only a person can do.Map the send call
Brevo's POST /v3/smtp/email and our POST /v1/email/messages are close in shape. Most of the work is unwrapping Brevo's address objects into plain strings.
| What it does | Brevo | Bird |
|---|---|---|
| Auth | api-key header | Authorization: Bearer |
| Sender | sender ({email, name}) | from |
| Recipients | to / cc / bcc (arrays of {email, name}) | to / cc / bcc (arrays of addresses) |
| Subject | subject | subject |
| Body | htmlContent / textContent | html / text (at least one) |
| Reply-to | replyTo ({email, name}) | reply_to (array) |
| Custom headers | headers (Title-Case keys) | headers (string → string object) |
| Filterable labels | tags (array of strings) | tags: {name, value} pairs |
| Stored template | templateId + params | template + template.parameters |
| Attachments | attachment (url or base64 content) | attachments (base64 only, see below) |
| Scheduling | scheduledAt | scheduled_at |
| Batch handle | batchId | (no counterpart, see below) |
| Per-recipient copy | messageVersions | one send per version, or a batch |
| Category | (none) | category: marketing (default) or transactional |
Our field caps and defaults (recipient counts, tag and metadata limits) live in Sending email.
Porting notes:
- Addresses are objects there and strings here. {"email": "a@x.com", "name": "A"} becomes "A <a@x.com>" or just "a@x.com". The same unwrapping applies to sender and replyTo.
- tags are bare strings. Our tags are pairs. A tag like "welcome" becomes {"name": "category", "value": "welcome"}. Pick a stable name so your dashboards filter the way your Brevo tag stats did.
- params is template data, not round-trip context. It becomes template.parameters. If you were also using it to carry your own identifiers through to events, move those to metadata, which we echo back on every webhook event alongside email_id/recipient_id.
- messageVersions has no single-call equivalent. Each version is a distinct recipient set and payload, so it becomes either its own send or one entry in a batch.
- batchId has no counterpart. Brevo's batchId groups scheduled messages so you can cancel or reschedule them as a set. Scheduled sends here are addressed individually by their message id; there is no group handle to pass or cancel against.
- Attachment by URL is not supported. Brevo accepts an attachment entry as a URL for it to fetch. Fetch the file yourself and send it base64-encoded; see attachments.
Export suppressions
Brevo splits suppressions across two systems that do not share an endpoint or a pagination scheme, and a migration that pulls only the first silently loses every marketing unsubscribe:
- Transactional blocks and unsubscribes: GET /v3/smtp/blockedContacts, paginated (50 per page by default, 100 maximum), each entry carrying the reason it was blocked.
- Marketing blocklist: not a suppression endpoint at all. It lives on the contact record as emailBlacklisted, so page GET /v3/contacts (up to 1000 per page with offset) and keep the contacts where that flag is true.
Run both through the import loop. Brevo's block reasons map onto our hard_bounce, complaint, and manual reasons; Suppressions has the full taxonomy.
Translate webhook events
| Outcome | Brevo | Bird |
|---|---|---|
| Accepted/processed | request | email.accepted → email.processed |
| Delivered | delivered | email.delivered |
| Temporary failure | deferred / soft_bounce | email.deferred |
| Permanent bounce | hard_bounce | email.bounced / email.out_of_band_bounce |
| Spam complaint | spam | email.complained |
| Blocked/suppressed | blocked / invalid_email | email.rejected |
| Open | opened / unique_opened | email.opened |
| Click | click | email.clicked |
| Unsubscribe | unsubscribed | email.unsubscribed / email.list_unsubscribed |
Two differences decide how much of your handler changes.
Check what your endpoint relies on today before you port it. Brevo's webhook security page documents credentials you configure on the endpoint: a username and password appended to the URL as https://username:password@example.com/, a bearer token, custom request headers, and its IP ranges. We sign every delivery per the Standard Webhooks HMAC scheme instead, so verification moves from something the caller carries to something your handler computes. If your current endpoint holds credentials in its URL, take them out and rotate that pair rather than reusing it: a credential that has lived in a URL has reached access logs, config exports, and a vendor console. The recipe is in Webhooks and events.
Brevo distinguishes opens and clicks from their unique variants. We do not. opened and unique_opened both arrive as email.opened, so a handler that counted only the unique variant needs to deduplicate on recipient_id itself. Our delivery events are recipient-scoped, so a send to three recipients produces three delivery outcomes rather than one.
Cut over
Work through domains and DNS and the sandbox smoke test in the main guide. Both are provider-independent.
Next steps
- Sending domains: registration, verification lifecycle, and the DNS records you are publishing
- Webhooks and events: endpoint setup and Standard Webhooks verification
- Testing sandbox: smoke-test the new integration before cutover
- Suppressions: confirm your imported list and how we maintain it from here