From Brevo to Postal, leaving behind what isn't email
Brevo, formerly Sendinblue, bundles many tools. Sending by API and SMTP, templates, contacts and webhooks move to Postal, in other formats. SMS, WhatsApp, CRM and the rest stay out: this guide shows what migrates, what you redo and what has no counterpart.
- Both sending paths
- API and SMTP
- To export your base
- CSV or API
- SMS and WhatsApp stay on Brevo
- Email only
- Campaigns and automations
- Redo
Overview
What comes along and what you redo
Sending, templates, contacts and webhooks move to Postal, but nothing happens on its own: you export, convert and check. Brevo's other products have no counterpart on Postal.
Comes by API or file
Contacts with their attributes and lists, who is unsubscribed or blocked, and the HTML of transactional templates.
You rewrite
The send call, the SMTP configuration, the templates (params become variables) and the webhooks, which change format.
Stays on Brevo
SMS, WhatsApp, sales CRM, meetings and phone, push notifications, payments, loyalty and coupons. Postal is an email platform.
Equivalence
The same concepts, other names
Brevo bundles several tools in one account. Postal takes care of email: sending, contacts, broadcasts and automations. Use this table as a dictionary.
| On Brevo | On Postal | How it works on Postal |
|---|---|---|
Transactional email (POST /v3/smtp/email) | POST /emails | Sending by API, with a ps_… key in the Authorization header. |
| SMTP relay | Postal SMTP | smtp.postal.so, user postal and an API key as the password. |
Transactional templates (templateId, params) | Templates (template.id, variables) | {{{NOME}}} variables that accept only text or numbers. You edit a draft and publish. |
| Senders and domains | Domains | The domain only needs to be verified: any address on it can be the sender. |
| Contacts and attributes | Contacts and properties | Each email appears once per team. Properties are typed: text, number, yes/no or date. |
| Lists | Static segments | A list built by hand or by import. For choices about what a contact wants to hear about, use topics. |
| List folders | No equivalent | Segments are not kept in folders. |
| Segments | Dynamic segments | Defined by filters and updated automatically. You redo the filters. |
| Contact on the blocklist | Unsubscribed or suppression list | An unsubscribed contact gets no broadcasts or automations. With suppression, the address is left out of all sends. |
| Email campaigns | Campaigns | Sending to a segment, and, if you want, only to those subscribed to a topic. |
| Automations | Automations | A trigger and steps: wait, send email, condition, change the contact and call a webhook. |
| Transactional and marketing webhooks | Webhooks | One endpoint per URL, with the events you choose and a signature on each delivery. |
Take inventory on Brevo
Before changing anything, list what your system uses on Brevo. Each item has a place on Postal, or doesn't, and it is better to find that out now.
| On Brevo | What to note | Where it lives on Postal |
|---|---|---|
| Senders and domains | Authenticated domains and the senders your system uses | Domains |
| API keys and SMTP credentials | Who calls the API (api-key header) and who uses SMTP | API keys |
| Transactional templates | The ID, name and params of each one | Templates |
| Webhooks | The URL, the events and how it authenticates (Brevo accepts up to 40, marketing and transactional combined) | Webhooks |
| Contacts | Lists, segments, attributes and who is unsubscribed or on the blocklist | Audience |
| Campaigns, automations and forms | What is live and what still makes sense | Campaigns, Automations and Forms |
| SMS, WhatsApp, CRM, meetings, push, payments and loyalty | What depends on each one | No equivalent: decide where this lives |
Verify the domain on Postal
Under Domains, Postal shows a few records for whoever manages DNS to copy and checks them on its own for up to 72 hours. The domain can stay authenticated on Brevo at the same time: the record names are different.
| On Brevo (examples from the documentation) | On Postal | |
|---|---|---|
| Verification code | TXT on the domain itself, with value brevo-code:… | None: just publish Postal's records |
| DKIM | TXT on mail._domainkey, with a value that starts with k=rsa;p= | TXT on postal._domainkey |
| DMARC | TXT on _dmarc, for example v=DMARC1; p=none; rua=mailto:… | Optional. The same _dmarc works for both |
| Return-Path and SPF | The Brevo documentation consulted does not mention them | MX and TXT on postal |
| How to confirm | Through the dashboard or the API (/v3/senders/domains/{domínio}); there is also the option of hosting a file or sending an email with a code | On its own, for up to 72 hours, with the status of each record |
- Copy the values from your own Brevo screen: the ones in the table are examples from the documentation, and your verification code is unique.
- Because the names are different (
mail._domainkeyin the Brevo example,postal._domainkeyon Postal), both stay valid during the cutover. Check whether any name in your account is the same before publishing. - Postal notifies you through the
domain.updatedwebhook when the domain becomesverified. See Domains and DNS.
Rewrite the send call
Postal does not accept Brevo's format. The call becomes POST /emails, with the key in Authorization: Bearer instead of the api-key header, and the fields change names. Below, the same email in both formats.
POST https://api.brevo.com/v3/smtp/email
api-key: <Brevo key>
Content-Type: application/json
{
"sender": { "name": "Aurora Cosméticos", "email": "[email protected]" },
"to": [{ "email": "[email protected]", "name": "Ana" }],
"subject": "Your order #10482 is out for delivery",
"htmlContent": "<p>Order <b>#10482</b> is on its way.</p>",
"textContent": "Order #10482 is on its way.",
"tags": ["shipping"]
}POST https://api.postal.so/emails
Authorization: Bearer ps_your_key
Idempotency-Key: order-10482-shipped
Content-Type: application/json
{
"from": "Aurora Cosméticos <[email protected]>",
"to": ["Ana <[email protected]>"],
"subject": "Your order #10482 is out for delivery",
"html": "<p>Order <b>#10482</b> is on its way.</p>",
"text": "Order #10482 is on its way.",
"tags": [{ "name": "shipping" }]
}| Brevo field | On Postal | What changes |
|---|---|---|
sender: {email, name} or {id} | from: text Nome <email> | There is no sender ID. The domain must be verified (and be the key's domain, if the key is restricted). |
to: list of {email, name}, up to 2,000 per request | to: text or a list of up to 50, in the format Nome <email> | A list on Postal is a single email in which everyone sees each other. For one email per person, use the batch. |
messageVersions (up to 99 recipients per version) | POST /emails/batch: list of up to 100 emails, each with its own to | Each item has its own subject, content or template. The batch is validated first: either all are queued or none. It does not accept attachments or scheduled_at and depends on the plan. |
cc, bcc | cc, bcc | Text or list. |
replyTo: {email, name} | reply_to: text or list | |
subject, htmlContent, textContent | subject, html, text | Without text, html is required. Send both when you can. |
templateId and params | template: {id, variables} | The numeric ID becomes the template's ID or alias. Do not send html along with it. See the templates step. |
headers | headers | Brevo accepts only non-standard headers. On Postal, some names cannot be overridden: From, To, Cc, Bcc, Subject, Reply-To, Sender, Date, Message-ID, Return-Path, MIME-Version, Content-Type, Content-Transfer-Encoding and DKIM-Signature. |
tags: list of text | tags: list of {name, value} (up to 50) | Name and value accept only ASCII letters, numbers, _ and -: “Promoção de Natal” becomes promocao-de-natal. The value is optional. |
attachment: {url} or {content, name} | attachments: {path} or {content, filename} | url becomes path and name becomes filename. Up to 40 MB in total, not valid in a batch and depends on the plan. |
scheduledAt, in UTC (delivery may be delayed by up to 5 minutes) | scheduled_at, in ISO 8601 | In the future and at most 30 days ahead. You can reschedule (PATCH /emails/{id}) and cancel (POST /emails/{id}/cancel). Depends on the plan. |
batchId | No equivalent | Cancel each scheduled email, one by one. |
201 response with messageId | 200 response with id | Store the id: it is what appears in webhooks (data.email_id) and in GET /emails/{id}. |
Errors: {code, message} | Errors: {statusCode, name, message} | See Errors. |
| Up to 1,000 requests per second (general range across plans) | Per plan: Free 5, Pro 10, Scale 20 and Enterprise 50 | In a batch, 100 emails count as one request. See Limits. |
- Without
templateId, Brevo requiressender,subjectandhtmlContent. Postal requiresfrom,subjectandhtmlortext. - Postal adds the
Idempotency-Keyheader: repeating the call with the same key and the same body, within 24 hours, returns the first call's response and does not send again. See Idempotency. - Postal's documentation does not cover dedicated IPs. If you use the
sender.ipfield on Brevo, talk to us through the contact form before migrating. - Prefer an SDK? Postal's (Node.js, PHP and Laravel) use this same API. See SDKs and CLI and the emails reference.
If you send over SMTP, change only the connection
Systems that speak SMTP, such as WordPress, ERPs and store platforms, change four fields: server, port, username and password. On Brevo the password is an SMTP key, not the API key. On Postal it is an API key.
- MAIL_HOST=smtp-relay.brevo.com- MAIL_PORT=587- MAIL_USERNAME=<login SMTP do Brevo>- MAIL_PASSWORD=<chave SMTP do Brevo>+ MAIL_SCHEME=smtps+ MAIL_HOST=smtp.postal.so+ MAIL_PORT=465+ MAIL_USERNAME=postal+ MAIL_PASSWORD=ps_sua_chave| Brevo SMTP | Postal SMTP | |
|---|---|---|
| Server | smtp-relay.brevo.com | smtp.postal.so |
| Ports | 587 or 2525 (the documentation says to leave encryption empty) and 465 (SSL/TLS) | 465 (direct TLS, recommended) or 587 (STARTTLS). If the network blocks them, 2465 (TLS) or 2587 (STARTTLS) |
| Username | The SMTP login from the SMTP tab, under SMTP and API | postal |
| Password | The SMTP key (the documentation warns it is not the API key) | An API key (ps_…). A send-only key is enough |
| Batch sending | The relay does not do batches: the documentation says to use the API | Up to 50 recipients per message. For batches, the API |
- On Postal, authentication (
AUTH PLAINorAUTH LOGIN) always comes after TLS. - Postal SMTP has no tags, scheduling or templates: for those, use the API. If your system sends Brevo-specific headers for those features, Postal does not interpret them and they stay as ordinary headers.
- SMTP sends follow the same rules as the API (verified domain, quotas and suppression) and show up in Emails and Logs. See the SMTP guide.
Recreate the transactional templates
Postal does not convert templates from another platform, but the HTML comes through the Brevo API. The script below lists the active templates, replaces {{params.nome}} with {{{nome}}} and creates each one as a template, with the alias brevo-<id>. It is a starting point: review the result before publishing yours.
const brevoHeaders = { 'api-key': process.env.BREVO_API_KEY }
const postalHeaders = {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
}
// Brevo {{params.nome}} becomes {{{nome}}} in Postal
const convert = (text) => text.replace(/\{\{\s*params\.(\w+)\s*\}\}/g, '{{{$1}}}')
const url = 'https://api.brevo.com/v3/smtp/templates?templateStatus=true&limit=100'
const response = await fetch(url, { headers: brevoHeaders })
const { templates } = await response.json()
for (const template of templates) {
const created = await fetch('https://api.postal.so/templates', {
method: 'POST',
headers: postalHeaders,
body: JSON.stringify({
name: template.name,
alias: `brevo-${template.id}`,
subject: convert(template.subject),
html: convert(template.htmlContent),
}),
})
if (!created.ok) throw new Error(await created.text())
const { id } = await created.json()
await fetch(`https://api.postal.so/templates/${id}/publish`, { method: 'POST', headers: postalHeaders })
}| On Brevo | On Postal | Note |
|---|---|---|
{{params.codigo}} | {{{codigo}}} | The value comes in template.variables. In the HTML, values are escaped. |
{{ contact.FIRSTNAME }} (contact data) | {{{FIRST_NAME}}} or {{{contact.chave}}}, only in broadcasts and automations | In a send through POST /emails, pass the value in variables. |
{% if … %} and {% for … %} | No equivalent | Postal templates only substitute variables. Resolve conditions and lists in your system and send the finished result. |
| Brevo unsubscribe link | {{{POSTAL_UNSUBSCRIBE_URL}}} | In broadcasts, without the variable Postal adds an unsubscribe footer at the end of the email. |
- Brevo accepts
paramsonly in templates written in the “New Template Language”. Older templates need to be reviewed by hand. paramsvalues that are lists or objects do not carry over: on Postal, each variable is text or a number, and a variable with no value and no default returns422. Declare optional ones withfallback_value.- Brevo's response includes each template's sender (
sender). On Postal, it comes fromfromin the send, or from the default sender you store on the template. - A send always uses the latest published version. See the templates reference; the plan defines how many templates the team has.
Recreate the webhooks and switch authentication
The main events exist on both sides, with different names and a different body. On Brevo, the documentation consulted does not describe a signature: it authenticates with a username and password in the URL, a Bearer token, custom headers or an IP list. On Postal, each delivery comes signed with the endpoint's secret.
Brevo event (event) | Postal event | Note |
|---|---|---|
request | email.sent | Approximate: Postal notifies you when it handed the email to the sending provider. |
delivered | email.delivered | The recipient's server accepted the message. |
deferred | email.delivery_delayed | Retries happen automatically. |
soft_bounce | email.bounced | data.bounce.type is Transient. |
hard_bounce | email.bounced | data.bounce.type is Permanent, and the address goes on the suppression list. |
invalid_email, error | email.failed | Approximate: failed.reason carries the reason. |
blocked | email.suppressed | Approximate: Postal uses it when the recipient was on the suppression list. Check with a test send. |
spam | email.complained | The address goes on the suppression list. |
opened, unique_opened | email.opened | Only with open tracking turned on for the domain. Postal's documentation does not separate the first open from later ones: if you need that, count by email_id. |
click | email.clicked | data.click carries link, ipAddress, userAgent and timestamp. Only with click tracking turned on. |
unsubscribed | contact.updated | A marketing contact's unsubscribe generates contact.updated. |
proxy_open, unique_proxy_open | No equivalent | Postal has no event for proxy opens. |
Marketing: contact_updated, contact_deleted | contact.updated, contact.deleted | |
Marketing: list_addition | No event of its own | Static segments don't generate an event. |
| On Brevo | On Postal | Note |
|---|---|---|
Loose fields in the body: event, email, message-id, ts_event… | {type, created_at, data}, with the email inside data | If you turned on batched webhooks (batched, up to 500 events per delivery, every 5 minutes), your code receives several per request. Postal sends one event per request. |
message-id, the identifier of the email in the event | data.email_id | It's the id returned by POST /emails: store it to tie the event to the send. |
email: one address | data.to: a list | |
ts_event in UTC; date in CET/CEST | created_at in ISO 8601 | |
tags and tag | data.tags: object {nome: valor} | |
reason | data.bounce.message and data.failed.reason | |
X-Mailin-custom, returned in the event | No equivalent | Keep your data in your database, alongside the email's id, or use tags. |
| No documented signature: username and password in the URL, Bearer token, custom headers or an IP list | Standard Webhooks spec, HMAC-SHA256: webhook-id, webhook-timestamp and webhook-signature | The secret (whsec_…) belongs to each endpoint. See the signature verification. |
| 4 retries (after 10 minutes, 1 hour, 2 hours and 8 hours). A 4xx response (except 429) or a 5xx ends delivery and discards the event | 8 attempts over about 27 hours. Any response that isn't 2xx counts as a failure and triggers a new attempt | All with the same webhook-id: use it to discard duplicates. Manual resend from the dashboard or via POST /webhooks/{id}/events/{event_id}/replay. |
| Up to 40 webhooks, marketing and transactional combined | The number of endpoints depends on the plan | The URL must be HTTPS and public. You choose the events for each endpoint. |
- Create the endpoint in Webhooks (dashboard or
POST /webhooks) and store thesigning_secret. Send a test event from the dashboard before switching over. - Test addresses (
[email protected],[email protected]and others) generate real webhooks without using your quota. They depend on the plan. - Remove the Brevo webhooks only after switching all your traffic: emails that still go out through Brevo keep generating events.
Bring over people who are blocked or asked to leave
Do this before importing contacts: an address on the suppression list never becomes a contact. Through its API, Brevo lists the contacts blocked or unsubscribed from transactional emails, with the reason for each one.
The hardBounce and contactFlaggedAsSpam reasons on the blocked list.
Put them on the suppression list.
The unsubscribedViaMA, unsubscribedViaEmail, unsubscribedViaApi and adminBlocked reasons.
Suppress them if the person shouldn't receive even system emails. If only marketing stopped, import them as unsubscribed.
Contacts who agreed to receive emails.
Import them as contacts, in the next step.
Contacts who have a phone number and no email.
Leave them out: Postal doesn't have those channels.
const reasonsToSuppress = new Set(['hardBounce', 'contactFlaggedAsSpam'])
const emails = []
for (let offset = 0; ; offset += 100) {
const url = `https://api.brevo.com/v3/smtp/blockedContacts?limit=100&offset=${offset}`
const response = await fetch(url, { headers: { 'api-key': process.env.BREVO_API_KEY } })
const { contacts = [] } = await response.json()
if (contacts.length === 0) break
for (const contact of contacts) {
if (reasonsToSuppress.has(contact.reason?.code)) emails.push(contact.email.toLowerCase())
}
}
for (let i = 0; i < emails.length; i += 100) {
const response = await fetch('https://api.postal.so/suppressions/batch/add', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.POSTAL_API_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ emails: emails.slice(i, i + 100) }),
})
if (!response.ok) throw new Error(await response.text())
await new Promise((resolve) => setTimeout(resolve, 250)) // respeita o limite do plano
}- The route is
GET /v3/smtp/blockedContacts, with up to 100 per page. Each item hasemail,blockedAtandreason.code. The script brings over only the two delivery reasons and leaves the rest for you to decide. - In Postal, the addresses come in with the source
manual: the difference between a bounce and a complaint isn't kept. The key must have full access. See the suppressions reference. - Brevo also has a list of blocked domains. Postal's suppression list stores addresses, not whole domains: if you need that, reject the domain in your form or in your system.
Export your Brevo base and import it into Postal
Brevo's dashboard exports contacts as CSV, and so does the API. In CRM, Contacts, select the contacts and use More actions, Export, choosing the attributes. Through the API, the request below generates the file and notifies your notifyUrl when it's ready.
POST https://api.brevo.com/v3/contacts/export
api-key: <chave do Brevo>
Content-Type: application/json
{
"customContactFilter": { "actionForContacts": "subscribed", "listId": 12 },
"exportAttributes": ["FIRSTNAME", "LASTNAME"],
"notifyUrl": "https://exemplo.com.br/hooks/brevo-export"
}- In
actionForContacts, the API acceptsallContacts,subscribed,unsubscribedandunsubscribedPerList. You must also providelistId,segmentId,modifiedSinceorcreatedSince(a full date, with time zone). Export subscribed and unsubscribed contacts in separate files. - Without CSV,
GET /v3/contacts(up to 1,000 per page) andGET /v3/contacts/lists/{id}/contacts(up to 500) return JSON withattributes,listIds,listUnsubscribedandemailBlacklisted. - Unsubscribed: in their file, add an Unsubscribed column with
sim(yes) on every row and map that column to the Unsubscribed (yes/no) field. Anyone withemailBlacklistedset to true goes here, never in as a subscriber. - Lists: export one list per file and import each one, choosing the segment. Only static segments receive imported contacts. People who left a single list (
listUnsubscribed) need care: if the list became a topic, record those people'sopt_outchoice withPATCH /contacts/{id}/topics. - First create the Properties for the attributes you want to keep: the import doesn't create new properties. If you use your own ID (
ext_id) and it comes in the file, map it to the external ID field. - Leave the Trigger automations option off. Contacts that already exist are updated by email, and each file can be up to 20 MB. Your plan's contact limit is on Pricing: 1,000 on Free, 5,000 on Pro and 50,000 on Scale.
- Before sending, run the list through Email validation.
Rebuild campaigns, automations and forms
Campaigns and automations don't carry over on their own. We found no export of them to another platform in Brevo's documentation, so treat each one as new and use the chance to review what still makes sense.
| On Brevo | On Postal | What to do |
|---|---|---|
| Email campaigns | Campaigns | Copy the HTML into a template or paste it into the campaign. Choose the segment (and, if you like, the topic), the sender from a verified domain and the subject. Campaigns that were already sent aren't imported. |
| Automations | Automations | Build it again in the flow editor: a trigger and steps to wait, send email, check a condition, change the contact and call a webhook. See Automations. |
| Segments | Dynamic segments | Rebuild the filters. Only the contacts come in the file. |
| Signup forms | Forms | Recreate them, turn on email confirmation (double opt-in) and swap the address or code on your site. See Forms. |
| Reports and history | Doesn't come over | Export whatever you want to keep before closing your account. Postal keeps emails, events and logs for 30 days. |
- Imports don't start automations unless you turn that option on. Review who would enter before you activate it.
- Each plan has a monthly limit on automation runs.
Check what has no equivalent and switch your traffic
Switch one flow at a time, with Brevo still on. Start with the test addresses and with yourself, compare deliveries and bounces over the first few days, and only then move the rest.
| On Brevo | What to do in Postal |
|---|---|
| SMS and WhatsApp | No equivalent. Postal is an email platform. |
| Sales CRM, meetings and phone, push notifications, payments, loyalty and coupons | No equivalent. Stay on Brevo or use another tool. |
| Templates with conditions and loops | Resolve them in your system and send the finished HTML, or simple variables. |
Dedicated IP (sender.ip) | Postal's documentation doesn't cover this. |
batchId | Cancel each scheduled email with POST /emails/{id}/cancel. |
| List of blocked domains | No equivalent: suppression stores addresses. |
| Webhooks authenticated by username and password in the URL, Bearer token or custom headers | Protection comes from the signature on each delivery. |
| Open events through a proxy | No event of its own. |
Before you flip the key
- The domain is verified in Postal and the API key is created (send-only and restricted to the domain, for systems that only send).
- The send call was rewritten and tested with a test address, and your code stores the
idof each email. - The templates were recreated and published, and the ones that used conditions or loops were resolved in your system.
- SMTP points to
smtp.postal.sowith an API key as the password. - The webhook was created, the signature is checked and a test event arrived.
- People blocked for bounces and spam are on the suppression list, before the contact import.
- People who were unsubscribed came in as unsubscribed, and automations were left off during the import.
- Your code records which provider each email went out through, and the bounces were checked over the first few days.
- You decided where SMS, WhatsApp and the CRM will live, if you used them.
Frequently asked questions
Do I need to keep Brevo during the migration?
Yes, until you finish: switching sending, bringing over contacts, rebuilding campaigns and automations, and seeing the events arrive for the last emails that went out through it. If you use SMS, WhatsApp or the CRM, they stay on Brevo, because Postal doesn't have those products.
Can I keep the domain authenticated on both?
Yes. The records have different names (Postal uses the postal selector for DKIM and the postal subdomain for the Return-Path), so both stay verified during the switch.
Do my templates with conditions and lists work in Postal?
Not the same way. Postal templates only substitute text or number variables. Resolve the conditions and lists in your system, build the final HTML and send it in html, or pass just the finished values in variables.
Brevo used to be Sendinblue. Does this guide apply to me?
Yes. The guide uses the names and routes from Brevo's current documentation (api.brevo.com/v3). If your code still uses the old name, check Brevo's documentation for which address it calls.