Migrate from Mailchimp to Loops

This guide covers moving from Mailchimp to Loops: swap the SDK, move your audience and templates, map each API call to its Loops equivalent, and cut over without hurting deliverability. It is written for a team running marketing on the Mailchimp Marketing API and, optionally, transactional email on Mailchimp Transactional (Mandrill), that wants both in one platform.

Migration guides

Should you do this?

Migrate if you run marketing sends and want your product, lifecycle, and transactional email in a single audience with one contact model and one API. Loops upserts contacts by email or userId directly, triggers workflows from product events, and sends transactional email from published templates. If you also run Mandrill for receipts and password resets, Loops covers that path too, so you can retire two integrations for one.

You may be better served elsewhere in a few cases, stated neutrally. If you need a raw SMTP relay at the lowest possible per-message cost with no audience layer, dedicated sending infrastructure is the better fit. If your workflow depends on Mailchimp-specific features such as its landing-page builder, ads, or the exact segmentation model your team is built around, confirm the equivalents in Loops before you commit. This guide will not pretend those are identical.

One structural change to plan for up front: in Mailchimp Transactional you can send HTML at request time, and members are identified by a subscriber hash (the MD5 of the lowercased email). In Loops, transactional content lives in a published template and code passes only dynamic values as dataVariables, and contacts are identified by email or userId with no hashing. Copy changes move into the Loops editor instead of a deploy. The tradeoff is that you keep dataVariables names in sync between the template and your code.

Swap the packages

Remove the Mailchimp packages and install the Loops SDK. The package is named exactly loops.

# Remove Mailchimp
npm uninstall @mailchimp/mailchimp_marketing @mailchimp/mailchimp_transactional

# Add Loops
npm

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

API mapping

Map each Mailchimp call to its Loops equivalent. The shapes are close, and most of the work is renaming fields.

Add or update a member. In Mailchimp you call POST /lists/{list_id}/members, or PUT /lists/{list_id}/members/{subscriber_hash} where subscriber_hash is the MD5 of the lowercased email. In Loops you call loops.updateContact({ email, properties, mailingLists }), which upserts by email (or userId) with no hashing. The SDK maps to PUT /v1/contacts/update.

Merge fields become contact properties. Mailchimp merge fields such as FNAME and LNAME map to Loops firstName and lastName. Other merge fields become custom properties in camelCase. You can create custom properties ahead of time with POST /v1/contacts/properties ({ name, type } where type is string, number, boolean, or date), or let them be set on first write.

Tags and segments become mailing lists. Mailchimp tags and segments organize the audience. In Loops, use mailing lists. Read your lists with GET /v1/lists to get their ids, then subscribe a contact by passing mailingLists: { [listId]: true } (or false to remove them from that list).

Transactional send. Mailchimp Transactional (Mandrill) uses messages/send or messages/send-template with template content and merge_vars. Loops uses POST /v1/transactional with a published transactionalId and dataVariables. dataVariables names are case-sensitive and must match the template exactly. There is no raw HTML at send time.

Error handling. Loops returns standard HTTP codes: 400 for a bad request or an unpublished transactional email, 401 for an invalid or disabled key, 404 not found, 409 for a conflict (already exists, or a reused idempotency key), 413 payload too large, 422 for an LMX compile failure, and 429 when you exceed the rate limit of 10 requests per second per team. Back off and retry on 429.

Before and after

Contact upsert

Before, with the Mailchimp Marketing SDK:

import mailchimp from "@mailchimp/mailchimp_marketing";

mailchimp.setConfig({
  apiKey: process.env.MAILCHIMP_API_KEY,
  server: process.env.MAILCHIMP_SERVER_PREFIX, // the <dc> datacenter suffix
});

import crypto from "crypto";
const subscriberHash = crypto
  .createHash("md5")
  .update("[email protected]".toLowerCase())
  .digest("hex");

await mailchimp.lists.setListMember(process.env.MAILCHIMP_LIST_ID, subscriberHash, {
  email_address: "[email protected]",
  status_if_new: "subscribed",
  merge_fields: { FNAME: "Ada", LNAME: "Lovelace" },
});

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 },
});

Transactional send

Before, with Mailchimp Transactional (Mandrill):

import mailchimpTransactional from "@mailchimp/mailchimp_transactional";

const mandrill = mailchimpTransactional(process.env.MANDRILL_API_KEY);

await mandrill.messages.sendTemplate({
  template_name: "password-reset",
  message: {
    to: [{ email: "[email protected]" }],
    merge_vars: [
      {
        rcpt: "[email protected]",
        vars: [{ name: "RESET_URL", content: "https://app.example.com/reset/abc" }],
      },
    ],
  },
});

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" },
});

Keep the dataVariables keys identical to the variable names in the published Loops template. A missing required variable returns 400.

Environment

Replace the Mailchimp variables with the Loops key and the ids you copy from the dashboard.

Before:

MAILCHIMP_API_KEY=...            # key ends with -<dc>, e.g. -us21
MAILCHIMP_SERVER_PREFIX=us21     # the <dc> datacenter suffix
MAILCHIMP_LIST_ID=...            # audience (list) id
MANDRILL_API_KEY=...             # Mailchimp Transactional key

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, so there is no datacenter prefix to configure. Authenticate with Authorization: Bearer YOUR_API_KEY.

Dashboard setup, the part code can’t do

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

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

Verify your sending domain. Mailchimp domain authentication (its SPF, DKIM, and CNAME records) does not carry over. During sending-domain setup, Loops shows the SPF, DKIM, MX, and a default DMARC record to add in your DNS, then verifies them 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.

Recreate each template and publish it. Campaigns you built in the Mailchimp editor are rebuilt in the Loops campaign editor. For transactional email, recreate each Mandrill template in Loops with matching data variables (case-sensitive), then publish it. A draft cannot send, and sending to an unpublished template returns 400. You can find a published transactionalId on the dashboard Transactional page or with GET /v1/transactional-emails.

Create mailing lists and copy their ids. Recreate the tags and segments you use as Loops mailing lists, then read their ids with GET /v1/lists and put them in your environment.

Cutover checklist

Import your audience. There is no public bulk-contact-import API in Loops. Export your Mailchimp audience to CSV and import it in the Loops dashboard under Audience -> import CSV. For a programmatic migration, loop loops.updateContact per contact and stay within the 10 requests per second limit.

Honor suppression and consent from day one. Export your Mailchimp unsubscribed and cleaned members 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 and cut over per email type. Move one email type at a time (for example, password resets first, then receipts, then marketing), rather than switching everything at once. Watch delivery events on each type before moving the next. Loops webhooks expose delivered, bounced, and complaint events for monitoring.

Decide on addToAudience. POST /v1/transactional accepts addToAudience: true, which adds the recipient to your marketing audience. Leave it off for messages like password resets where the recipient has not opted into marketing.

Use idempotency and respect the rate limit. Send an Idempotency-Key header (up to 100 characters) on events and transactional sends so a retry does not double-send. A key reused within 24 hours returns 409. The limit is 10 requests per second per team, and going over returns 429, so back off and 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 raw HTML from my code?

How long does the migration take?

Will my unsubscribes carry over?

How are contacts identified without a subscriber hash?