New: campaigns, automations and product emails in the same account. Meet Postal
Sign in Create account

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 BrevoOn PostalHow it works on Postal
Transactional email (POST /v3/smtp/email)POST /emailsSending by API, with a ps_… key in the Authorization header.
SMTP relayPostal SMTPsmtp.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 domainsDomainsThe domain only needs to be verified: any address on it can be the sender.
Contacts and attributesContacts and propertiesEach email appears once per team. Properties are typed: text, number, yes/no or date.
ListsStatic segmentsA list built by hand or by import. For choices about what a contact wants to hear about, use topics.
List foldersNo equivalentSegments are not kept in folders.
SegmentsDynamic segmentsDefined by filters and updated automatically. You redo the filters.
Contact on the blocklistUnsubscribed or suppression listAn unsubscribed contact gets no broadcasts or automations. With suppression, the address is left out of all sends.
Email campaignsCampaignsSending to a segment, and, if you want, only to those subscribed to a topic.
AutomationsAutomationsA trigger and steps: wait, send email, condition, change the contact and call a webhook.
Transactional and marketing webhooksWebhooksOne endpoint per URL, with the events you choose and a signature on each delivery.
1

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 BrevoWhat to noteWhere it lives on Postal
Senders and domainsAuthenticated domains and the senders your system usesDomains
API keys and SMTP credentialsWho calls the API (api-key header) and who uses SMTPAPI keys
Transactional templatesThe ID, name and params of each oneTemplates
WebhooksThe URL, the events and how it authenticates (Brevo accepts up to 40, marketing and transactional combined)Webhooks
ContactsLists, segments, attributes and who is unsubscribed or on the blocklistAudience
Campaigns, automations and formsWhat is live and what still makes senseCampaigns, Automations and Forms
SMS, WhatsApp, CRM, meetings, push, payments and loyaltyWhat depends on each oneNo equivalent: decide where this lives
2

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.

Brevo and Postal records side by side
On Brevo (examples from the documentation)On Postal
Verification codeTXT on the domain itself, with value brevo-code:…None: just publish Postal's records
DKIMTXT on mail._domainkey, with a value that starts with k=rsa;p=TXT on postal._domainkey
DMARCTXT on _dmarc, for example v=DMARC1; p=none; rua=mailto:…Optional. The same _dmarc works for both
Return-Path and SPFThe Brevo documentation consulted does not mention themMX and TXT on postal
How to confirmThrough 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 codeOn 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._domainkey in the Brevo example, postal._domainkey on 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.updated webhook when the domain becomes verified. See Domains and DNS.
3

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.

Before: Brevo, POST /v3/smtp/email
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"]
}
201{ "messageId": "<…>" }
After: Postal, POST /emails
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" }]
}
200{ "id": "019a1b2c-4d5e-7f60-8a9b-0c1d2e3f4a5b" }
POST /v3/smtp/email and POST /emails, field by field
Brevo fieldOn PostalWhat 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 requestto: 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 toEach 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, bcccc, bccText or list.
replyTo: {email, name}reply_to: text or list
subject, htmlContent, textContentsubject, html, textWithout text, html is required. Send both when you can.
templateId and paramstemplate: {id, variables}The numeric ID becomes the template's ID or alias. Do not send html along with it. See the templates step.
headersheadersBrevo 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 texttags: 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 8601In the future and at most 30 days ahead. You can reschedule (PATCH /emails/{id}) and cancel (POST /emails/{id}/cancel). Depends on the plan.
batchIdNo equivalentCancel each scheduled email, one by one.
201 response with messageId200 response with idStore 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 50In a batch, 100 emails count as one request. See Limits.
  • Without templateId, Brevo requires sender, subject and htmlContent. Postal requires from, subject and html or text.
  • Postal adds the Idempotency-Key header: 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.ip field 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.
4

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.

Laravel .env
- 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
SMTP configuration on both sides
Brevo SMTPPostal SMTP
Serversmtp-relay.brevo.comsmtp.postal.so
Ports587 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)
UsernameThe SMTP login from the SMTP tab, under SMTP and APIpostal
PasswordThe SMTP key (the documentation warns it is not the API key)An API key (ps_…). A send-only key is enough
Batch sendingThe relay does not do batches: the documentation says to use the APIUp to 50 recipients per message. For batches, the API
  • On Postal, authentication (AUTH PLAIN or AUTH 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.
5

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.

Node.js: from Brevo templates to Postal templates
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 })
}
Template variables
On BrevoOn PostalNote
{{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 automationsIn a send through POST /emails, pass the value in variables.
{% if … %} and {% for … %}No equivalentPostal 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 params only in templates written in the “New Template Language”. Older templates need to be reviewed by hand.
  • params values 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 returns 422. Declare optional ones with fallback_value.
  • Brevo's response includes each template's sender (sender). On Postal, it comes from from in 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.
6

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.

Events: what maps to what
Brevo event (event)Postal eventNote
requestemail.sentApproximate: Postal notifies you when it handed the email to the sending provider.
deliveredemail.deliveredThe recipient's server accepted the message.
deferredemail.delivery_delayedRetries happen automatically.
soft_bounceemail.bounceddata.bounce.type is Transient.
hard_bounceemail.bounceddata.bounce.type is Permanent, and the address goes on the suppression list.
invalid_email, erroremail.failedApproximate: failed.reason carries the reason.
blockedemail.suppressedApproximate: Postal uses it when the recipient was on the suppression list. Check with a test send.
spamemail.complainedThe address goes on the suppression list.
opened, unique_openedemail.openedOnly 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.
clickemail.clickeddata.click carries link, ipAddress, userAgent and timestamp. Only with click tracking turned on.
unsubscribedcontact.updatedA marketing contact's unsubscribe generates contact.updated.
proxy_open, unique_proxy_openNo equivalentPostal has no event for proxy opens.
Marketing: contact_updated, contact_deletedcontact.updated, contact.deleted
Marketing: list_additionNo event of its ownStatic segments don't generate an event.
Webhooks: format, authentication and retries
On BrevoOn PostalNote
Loose fields in the body: event, email, message-id, ts_event…{type, created_at, data}, with the email inside dataIf 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 eventdata.email_idIt's the id returned by POST /emails: store it to tie the event to the send.
email: one addressdata.to: a list
ts_event in UTC; date in CET/CESTcreated_at in ISO 8601
tags and tagdata.tags: object {nome: valor}
reasondata.bounce.message and data.failed.reason
X-Mailin-custom, returned in the eventNo equivalentKeep 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 listStandard Webhooks spec, HMAC-SHA256: webhook-id, webhook-timestamp and webhook-signatureThe 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 event8 attempts over about 27 hours. Any response that isn't 2xx counts as a failure and triggers a new attemptAll 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 combinedThe number of endpoints depends on the planThe URL must be HTTPS and public. You choose the events for each endpoint.
  • Create the endpoint in Webhooks (dashboard or POST /webhooks) and store the signing_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.
7

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.

Bounce and spamSuppress

The hardBounce and contactFlaggedAsSpam reasons on the blocked list.

Put them on the suppression list.

Unsubscribed and blockedDecide

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.

SubscribedImport

Contacts who agreed to receive emails.

Import them as contacts, in the next step.

SMS or WhatsApp onlyDon't import

Contacts who have a phone number and no email.

Leave them out: Postal doesn't have those channels.

Node.js: from Brevo's blocked contacts to Postal's suppression list
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
}
201Each batch accepts up to 100 addresses
  • The route is GET /v3/smtp/blockedContacts, with up to 100 per page. Each item has email, blockedAt and reason.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.
8

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.

Request the export of a list
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"
}
202{ "processId": 3912 }
  • In actionForContacts, the API accepts allContacts, subscribed, unsubscribed and unsubscribedPerList. You must also provide listId, segmentId, modifiedSince or createdSince (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) and GET /v3/contacts/lists/{id}/contacts (up to 500) return JSON with attributes, listIds, listUnsubscribed and emailBlacklisted.
  • 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 with emailBlacklisted set 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's opt_out choice with PATCH /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.
9

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.

What to rebuild
On BrevoOn PostalWhat to do
Email campaignsCampaignsCopy 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.
AutomationsAutomationsBuild 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.
SegmentsDynamic segmentsRebuild the filters. Only the contacts come in the file.
Signup formsFormsRecreate them, turn on email confirmation (double opt-in) and swap the address or code on your site. See Forms.
Reports and historyDoesn't come overExport 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.
10

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.

Domain verifiedTest at [email protected]One flow on PostalAll trafficTurn off Brevo
What has no equivalent, and how to work around it
On BrevoWhat to do in Postal
SMS and WhatsAppNo equivalent. Postal is an email platform.
Sales CRM, meetings and phone, push notifications, payments, loyalty and couponsNo equivalent. Stay on Brevo or use another tool.
Templates with conditions and loopsResolve them in your system and send the finished HTML, or simple variables.
Dedicated IP (sender.ip)Postal's documentation doesn't cover this.
batchIdCancel each scheduled email with POST /emails/{id}/cancel.
List of blocked domainsNo equivalent: suppression stores addresses.
Webhooks authenticated by username and password in the URL, Bearer token or custom headersProtection comes from the signature on each delivery.
Open events through a proxyNo 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 id of 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.so with 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.

Want to try Postal?

Create your account and start on the Free plan.