Migrate from Mailchimp to Loops

Move Mailchimp 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?

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. 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.

One structural change to plan for up front: Mailchimp Transactional accepts HTML at send time. Separately, the Mailchimp Marketing API identifies audience members 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

Install the official loops package. Keep the Mailchimp 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 the Marketing and Transactional APIs separately. Review contact identity, permissions, template variables, and errors as part of each mapping.

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. Map other merge fields to explicitly named Loops custom properties. Names are not converted automatically. Create custom properties before the first write with POST /v1/contacts/properties ({ name, type } where type is string, number, boolean, or date). Map every merge field to a property you have created.

Rebuild audience rules. Use Loops mailing lists for subscription preferences, and segments for audiences that change with contact properties or engagement. Map tags to properties or static groups as appropriate. Read mailing-list IDs with GET /v1/lists, then set each contact’s permission with mailingLists: { [listId]: true } or false.

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.

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

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

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=
LOOPS_RESET_TEMPLATE_ID=
LOOPS_LIST_ID=

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 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.

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

Migrate each Mandrill 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 Mailchimp campaign replacements as Loops campaign drafts with POST /v1/campaigns.

Create mailing lists for subscription categories and copy their IDs from GET /v1/lists. Rebuild dynamic segments from the mapped contact properties instead of importing a segment snapshot as a permanent list.

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.

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 raw HTML from my code?

How long does the migration take?

Will my unsubscribes carry over?

How are contacts identified without a subscriber hash?