Migrate from Mailgun to Loops

This guide moves your transactional email from Mailgun to Loops. You will swap the SDK, move template content into published Loops templates, keep your send calls close to what they are today, and cut over without hurting deliverability.

Should you do this?

Mailgun is a developer email service. It sends over an HTTP API and an SMTP relay, organizes sending per domain, runs separate US and EU regions, stores Handlebars templates, and tracks suppressions (bounces, unsubscribes, complaints) per domain. It also receives inbound mail through Routes, which filter incoming messages and forward, store, or parse them.

Mailgun inbound routing has no Loops equivalent: if you use Routes to receive, parse, or forward incoming mail, keep Mailgun or another inbound provider for that path. Loops supports transactional template-based SMTP at smtp.loops.so:587, but not arbitrary raw HTML or MIME relay. Existing SMTP integrations can send Loops’ structured template payload over SMTP or move to the HTTP API.

Loops fits when you want transactional email and marketing 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 both jobs onto one platform, the migration is straightforward. The structural change to plan for: in Mailgun you can send raw HTML at request time or reference a stored template. In Loops the subject, body, and design live in a published template, and your code passes only dynamic values as dataVariables. Copy edits stop needing a code deploy. The tradeoff is that you keep the dataVariables names and your code in sync, because they are case-sensitive and must match the template exactly.

Swap the packages

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

npm uninstall mailgun.js form-data
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

Most of the migration is a direct swap. Where Loops has no equivalent, the list says so.

  • Transactional send. Mailgun POST /v3/{domain}/messages (or mg.messages.create) becomes Loops POST /v1/transactional (or loops.sendTransactionalEmail).

  • Authentication. Mailgun uses HTTP Basic auth, with username api and the API key as the password. Loops uses Authorization: Bearer YOUR_API_KEY.

  • Base URL and region. Mailgun is per-domain on api.mailgun.net (US) or api.eu.mailgun.net (EU). Loops has one base URL, https://app.loops.so/api, with no region prefix.

  • Templates and variables. Mailgun stores Handlebars {{ }} templates, sent with template=name plus t:variables or the h:X-Mailgun-Variables header. In Loops, a published template is referenced by transactionalId, and values are passed as dataVariables.

  • Contacts. In Mailgun, mailing lists hold member addresses; there is no marketing contact record with properties. In Loops, use loops.updateContact({ email, properties, mailingLists }), mapping to PUT /v1/contacts/update.

  • Mailing lists. Mailgun uses address-based mailing lists. In Loops, read ids from GET /v1/lists, then set mailingLists: { [listId]: true }.

  • Suppressions. Mailgun keeps per-domain /v3/{domain}/bounces, /unsubscribes, and /complaints. Loops exposes GET /v1/contacts/suppression and DELETE /v1/contacts/suppression, and auto-suppresses hard bounces and complaints.

  • Delivery events. Mailgun webhooks and events (delivered, failed, opened, clicked, complained, unsubscribed) map to Loops webhooks, which expose email.delivered, email.softBounced, email.hardBounced, email.spamReported, email.opened, email.clicked, and email.unsubscribed.

  • SMTP submission. Mailgun 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.

  • Inbound routing. Mailgun Routes receive, forward, store, and parse inbound mail. Loops has no inbound routing; keep Mailgun or another provider for received mail.

Before and after

Before, a Mailgun template send with the Node SDK:

import formData from "form-data";
import Mailgun from "mailgun.js";

const mailgun = new Mailgun(formData);
const mg = mailgun.client({ username: "api", key: process.env.MAILGUN_API_KEY });

await mg.messages.create(process.env.MAILGUN_DOMAIN, {
  from: "hello@yourdomain.com",
  to: user.email,
  subject: "Reset your password",
  template: "password-reset",
  "h:X-Mailgun-Variables": JSON.stringify({
    firstName: user.firstName,
    resetUrl: resetUrl,
  }),
});

After, the same send with Loops:

import { LoopsClient } from "loops";

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

await loops.sendTransactionalEmail({
  transactionalId: "clfq6dinx000yl70fgn6z11rd",
  email: user.email,
  dataVariables: {
    firstName: user.firstName,
    resetUrl: resetUrl,
  },
});

The from address, the subject, and the sending domain now live in the published Loops template, so they leave your code. The variable names (firstName, resetUrl) must match the names you define in the template, exactly, including case. If you send raw HTML from code today with Mailgun, that HTML moves into the Loops template once, and your code then passes only dynamic values.

Environment

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

# Before
MAILGUN_API_KEY=...          # HTTP Basic password, username is "api"
MAILGUN_DOMAIN=...           # per-domain sending, e.g. mg.yourdomain.com
# region base URL is api.mailgun.net (US) or api.eu.mailgun.net (EU)

# After
LOOPS_API_KEY=...            # team API key, Loops dashboard: Settings -> API
LOOPS_RESET_TEMPLATE_ID=...  # the published transactionalId (Transactional page)

Loops has one base URL, https://app.loops.so/api, so there is no region to configure. Each transactionalId comes from the Loops dashboard Transactional section (the Publish page shows the id and the expected payload), or from GET /v1/transactional-emails. Store the ids you use, or read them at startup.

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. Mailgun domain authentication (its SPF, DKIM, and tracking 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 changes can take up to an hour to propagate, so do this first. Sends only work from a verified domain.

  3. Migrate each Mailgun 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. The API returns the transactionalId and revision IDs for later edits.

  4. Create mailing lists and copy their ids. If you send to lists, create the matching mailing lists in Loops and copy each list id from GET /v1/lists for use in mailingLists objects.

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 suppressions over from day one. Export the Mailgun suppression lists for each domain (bounces, unsubscribes, complaints), and honor those addresses in Loops. Loops also auto-suppresses hard bounces and complaints, and you can inspect or remove a suppressed contact with GET /v1/contacts/suppression and DELETE /v1/contacts/suppression.

  • Run both providers in parallel, then cut over per email type. Keep Mailgun live, move one email type at a time (password reset first, then receipts, and so on), 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 dataVariables names are case-sensitive and must match the published template, a rename in one place needs the same rename in the other. A missing required variable fails with 400.

Common questions

Can I keep sending raw HTML from code like Mailgun's html parameter?

Does Loops replace Mailgun Routes and inbound email?

How long does the migration take?

Will my Mailgun suppressions carry over?