Migrate from Brevo to Loops

Move Brevo email to Loops by mapping templates and API calls, preserving subscription and suppression state, and testing each send path before cutover.

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, WhatsApp, CRM, or sales-pipeline features, so keep Brevo or another provider for those jobs. Loops supports transactional template-based SMTP at smtp.loops.so:587, but not arbitrary raw HTML or MIME relay. An app using smtp-relay.brevo.com can send Loops’ structured template payload over SMTP or move to the HTTP API.

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. Inventory the sending and contact-sync behavior you need to preserve before consolidating the two. 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

Install the official loops package. Keep the Brevo dependencies until no active send path or rollback plan uses them.

npm install loops

Keep the Loops SDK and API key on your server. Protect your own sending endpoint with access controls and input validation.

API mapping

Map transactional sends, contact properties, and list permissions separately. Validate each mapping before importing the full audience.

  • 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 (using email for this email migration) 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 submission. Brevo accepts conventional SMTP content. Loops accepts a structured transactional-template payload over SMTP at smtp.loops.so:587. Use the HTTP API when that integration model fits better.

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

Sending and contact-sync examples

Contact upsert

This helper accepts reconciled permission values from your app. Check current subscription and list preferences before calling it, including on retries. An old export must not overwrite a newer unsubscribe.

import { LoopsClient } from "loops";

const apiKey = process.env.LOOPS_API_KEY;
if (!apiKey) throw new Error("LOOPS_API_KEY is not set");
const loops = new LoopsClient(apiKey);

export async function syncContact(
  email: string,
  firstName: string,
  lastName: string,
  subscribed: boolean,
  listPermission: boolean,
) {
  const listId = process.env.LOOPS_LIST_ID;
  if (!listId) throw new Error("LOOPS_LIST_ID is not set");

  return loops.updateContact({
    email,
    properties: { firstName, lastName, subscribed },
    mailingLists: { [listId]: subscribed && listPermission },
  });
}

Map Brevo FIRSTNAME and LASTNAME to Loops firstName and lastName explicitly. Choose and document names for other contact properties. Names are not converted automatically. loops.updateContact upserts by email or userId without a separate update flag.

Transactional send

A server-side password-reset helper. Call it with a valid, unexpired reset URL from your authentication system and a persisted key for this reset request. Publish the template with firstName and resetUrl variables before testing.

import { LoopsClient } from "loops";

const apiKey = process.env.LOOPS_API_KEY;
if (!apiKey) throw new Error("LOOPS_API_KEY is not set");
const loops = new LoopsClient(apiKey);

export async function sendPasswordReset(
  email: string,
  firstName: string,
  resetUrl: string,
  sendKey: string,
) {
  const transactionalId = process.env.LOOPS_RESET_TEMPLATE_ID;
  if (!transactionalId) throw new Error("LOOPS_RESET_TEMPLATE_ID is not set");

  return loops.sendTransactionalEmail({
    transactionalId,
    email,
    dataVariables: { firstName, resetUrl },
    headers: { "Idempotency-Key": sendKey },
  });
}

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.

LOOPS_API_KEY=
LOOPS_RESET_TEMPLATE_ID=
LOOPS_LIST_ID=

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 and API-assisted migration

API key creation, sending-domain verification, mailing-list creation, and bulk CSV import still happen in the dashboard. Transactional templates and campaign drafts can be migrated with the Content API or CLI.

  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. Add the records early and wait for verification before testing sends. Propagation time depends on DNS caching. Sends only work from a verified domain.

  3. Migrate each Brevo template with the Content API or CLI. Create the transactional email, update its draft email message with LMX and matching case-sensitive variables, then publish it. Create campaign drafts with POST /v1/campaigns and update their email messages the same way.

  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

  • Export unsubscribe, complaint, invalid-address, and list-permission records before importing contacts. Reconcile them with any newer state already in Loops. Set subscribed: false for marketing opt-outs and preserve list-specific opt-outs. That flag does not block transactional mail: enforce imported delivery suppressions in your app before every send until the migration has an equivalent verified block. GET /v1/contacts/suppression inspects a Loops suppression and DELETE removes one. Neither imports a block.

  • Keep both providers available, with one provider owning each email type. Test content, variables, opt-outs, delivery suppressions, and duplicate prevention before moving a small cohort. Expand only after reviewing delivery events and failures. To roll back, pause the new send path and reconcile queued work before restoring the old owner.

  • Leave addToAudience off for account mail such as password resets. Adding a contact to the Audience does not establish marketing permission. Import and reconcile consent separately.

  • Keep one persisted Idempotency-Key, up to 100 characters, for each intended send or event. Reuse that key and the same payload on retries within the 24-hour window. Investigate a 409 conflict instead of generating a new key. Loops limits requests to 10 per second per team. Back off on 429. Provider idempotency does not deduplicate sends across two providers.

  • 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?