Migrate from Brevo to Loops

This guide moves your transactional email and marketing contacts from the Brevo v3 API to Loops. You will swap the SDK, move your contacts and templates, map each API call to its Loops equivalent, and cut over without hurting deliverability.

Migration guides

Should you do this?

Brevo is a multichannel platform. It sends transactional email over an HTTP API and an SMTP relay, holds a marketing contacts database with lists and folders, stores templates, and also sends SMS and WhatsApp on top of a CRM and marketing-automation suite. Loops is email only.

Loops has no SMS and no WhatsApp. If you send transactional SMS through Brevo (POST /v3/transactionalSMS/send) or run WhatsApp campaigns, Loops does not cover those channels, and you would keep Brevo or a dedicated provider for them. Loops also has no CRM or sales-pipeline features, and no raw SMTP relay: Loops sends over one HTTP API with Bearer auth, so an app that talks to smtp-relay.brevo.com today moves to API calls.

Loops fits when your need is email in one place: transactional templates for account mail, plus campaigns, contacts, mailing lists, and event-triggered workflows for lifecycle and marketing. If you are consolidating those onto one platform, the migration is straightforward. Two structural changes to plan for. In Brevo you can send htmlContent at request time. In Loops the subject, body, and design live in a published template, and your code passes only dynamic values as dataVariables. Brevo also identifies contacts by email, internal id, or ext_id, where Loops uses email or userId. Copy edits move into the Loops editor instead of a deploy. The tradeoff is that you keep the dataVariables names in sync between the template and your code.

Swap the packages

Remove the Brevo SDK and install the Loops SDK. The package is named loops, not loops-so or loops-js.

npm uninstall @getbrevo/brevo
npm

The Loops SDK is server-side only. Browser calls to the Loops API are blocked by CORS by design, so keep your API key on the server.

API mapping

Map each Brevo call to its Loops equivalent. Where Loops has no equivalent, the row says so.

  • Transactional send. Brevo POST /v3/smtp/email (or apiInstance.sendTransacEmail) becomes Loops POST /v1/transactional (or loops.sendTransactionalEmail).

  • Authentication. Brevo sends the api-key: YOUR_API_KEY header; Loops uses Authorization: Bearer YOUR_API_KEY.

  • Base URL. Brevo https://api.brevo.com/v3 becomes Loops https://app.loops.so/api.

  • Templates and variables. Brevo stores a template referenced by a numeric templateId, personalized with a params object and {{ params.X }} syntax. Loops references a published template by transactionalId and passes values as dataVariables.

  • Contact upsert. Brevo POST /v3/contacts with updateEnabled: true (identified by email, id, or ext_id) becomes Loops loops.updateContact({ email, properties, mailingLists }), mapping to PUT /v1/contacts/update (identified by email or userId).

  • Custom fields. Brevo attributes, created with POST /v3/contacts/attributes/{category}/{name} and sent UPPERCASE, become Loops contact properties, created with POST /v1/contacts/properties ({ name, type }, where type is string, number, boolean, or date) and sent camelCase.

  • Lists. Brevo GET /v3/contacts/lists with numeric listIds on the contact becomes Loops GET /v1/lists for ids, then mailingLists: { [listId]: true }.

  • Suppression and unsubscribes. Brevo manages these per list and account. Loops exposes GET /v1/contacts/suppression and DELETE /v1/contacts/suppression, and auto-suppresses hard bounces and complaints.

  • Delivery events. Brevo sends webhooks for delivered, soft bounce, hard bounce, spam, opened, click, and unsubscribe. Loops webhooks expose email.delivered, email.softBounced, email.hardBounced, email.spamReported, email.opened, email.clicked, and email.unsubscribed.

  • SMTP relay. Brevo offers smtp-relay.brevo.com with an SMTP key credential. Loops has no SMTP relay and sends over the HTTP API only.

  • SMS and WhatsApp. Brevo sends transactional and marketing SMS plus WhatsApp. Loops has neither; keep Brevo or a dedicated provider for those channels.

Before and after

Contact upsert

Before, with the Brevo SDK:

import { ContactsApi, CreateContact } from "@getbrevo/brevo";

const contactsApi = new ContactsApi();
contactsApi.authentications.apiKey.apiKey = process.env.BREVO_API_KEY;

const contact = new CreateContact();
contact.email = "[email protected]";
contact.attributes = { FIRSTNAME: "Ada", LASTNAME: "Lovelace" };
contact.listIds = [Number(process.env.BREVO_LIST_ID)];
contact.updateEnabled = true;

await contactsApi.createContact(contact);

After, with the Loops SDK:

import { LoopsClient } from "loops";

const loops = new LoopsClient(process.env.LOOPS_API_KEY);

await loops.updateContact({
  email: "[email protected]",
  properties: { firstName: "Ada", lastName: "Lovelace" },
  mailingLists: { [process.env.LOOPS_LIST_ID]: true },
});

Brevo attributes like FIRSTNAME and LASTNAME map to Loops firstName and lastName. Other UPPERCASE attributes become camelCase contact properties. loops.updateContact upserts by email or userId with no separate update flag.

Transactional send

Before, with the Brevo SDK:

import { TransactionalEmailsApi, SendSmtpEmail } from "@getbrevo/brevo";

const emailApi = new TransactionalEmailsApi();
emailApi.authentications.apiKey.apiKey = process.env.BREVO_API_KEY;

const message = new SendSmtpEmail();
message.to = [{ email: "[email protected]" }];
message.templateId = 12;
message.params = { resetUrl: "https://app.example.com/reset/abc" };

await emailApi.sendTransacEmail(message);

After, with Loops:

import { LoopsClient } from "loops";

const loops = new LoopsClient(process.env.LOOPS_API_KEY);

await loops.sendTransactionalEmail({
  transactionalId: process.env.LOOPS_RESET_TEMPLATE_ID,
  email: "[email protected]",
  dataVariables: { resetUrl: "https://app.example.com/reset/abc" },
});

The Brevo numeric templateId becomes a Loops transactionalId, and the params object becomes dataVariables. Keep the dataVariables keys identical to the variable names in the published Loops template, including case. A missing required variable returns 400.

Environment

Replace the Brevo variable with the Loops key and the ids you copy from the dashboard.

# Before
BREVO_API_KEY=...            # sent as the api-key header
BREVO_LIST_ID=...            # numeric list id

# After
LOOPS_API_KEY=...            # Settings -> API in the Loops dashboard
LOOPS_LIST_ID=...            # from GET /v1/lists, or the Lists page
LOOPS_RESET_TEMPLATE_ID=...  # the published transactionalId (Transactional page)

Loops has one base URL, https://app.loops.so/api. Authenticate with Authorization: Bearer YOUR_API_KEY, in place of the Brevo api-key header.

Dashboard setup, the part code can’t do

A few steps happen in the Loops dashboard, not in code.

  1. Create and test the API key. Generate a key under Settings -> API. Confirm it works with GET /v1/api-key, which returns { success: true, teamName }.

  2. Verify your sending domain. Brevo domain authentication (its SPF, DKIM, and DMARC records) does not carry over. In Loops, add the SPF, DKIM, MX, and default DMARC records Loops shows during sending-domain setup, then verify from the domain settings page. DNS can take up to an hour to propagate, so do this first. Sends only work from a verified domain.

  3. Recreate each template and publish it. Campaigns you built in the Brevo editor are rebuilt in the Loops campaign editor. For transactional email, recreate each Brevo template in Loops, map every {{ params.X }} value to a dataVariable of the same case-sensitive name, then publish. A draft cannot send: sending to an unpublished template returns 400. You can find a published transactionalId on the dashboard Transactional page or with GET /v1/transactional-emails.

  4. Recreate custom fields and lists. Recreate the attributes you use as Loops contact properties with POST /v1/contacts/properties, and recreate your lists as Loops mailing lists, then read their ids with GET /v1/lists.

For contacts, there is no public bulk-contact-import API. Bulk import is done in the dashboard under Audience -> import CSV. For a programmatic move, loop over loops.updateContact per contact, staying within the rate limit.

Cutover checklist

  • Carry suppression and consent over from day one. Export your Brevo unsubscribed and blocklisted contacts and carry that state into Loops. Set subscribed: false on those contacts, or use the suppression endpoints (GET /v1/contacts/suppression to inspect, DELETE /v1/contacts/suppression to remove a suppressed contact). Loops also auto-suppresses hard bounces and complaints going forward.

  • Run both providers in parallel, then cut over per email type. Keep Brevo live, move one email type at a time (password reset first, then receipts, then marketing), and watch delivery events for each before moving the next. Do not switch everything at once.

  • Mind the marketing audience flag. POST /v1/transactional accepts addToAudience. Leave it off for account mail like password resets. Set it to true only when you intend to add the recipient to your marketing audience.

  • Respect the rate limit. Loops allows 10 requests per second per team and returns 429 when you exceed it, so back off and retry. For sends and events you can pass an Idempotency-Key header (up to 100 characters). A key reused within 24 hours returns 409, which protects against duplicate sends on retry.

  • Keep template variables and code in sync. Because copy lives in the published template and code passes only dataVariables, a renamed variable in the editor must match the key in your code, or the send fails.

Common questions

Can I keep sending htmlContent from my code like Brevo?

Does Loops replace Brevo SMS and WhatsApp?

How long does the migration take?

Will my Brevo unsubscribes carry over?