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.

Migration guides

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.

Two things in Mailgun have no Loops equivalent. The first is inbound email routing: if you use Mailgun Routes to receive, parse, or forward incoming mail, Loops does not replace that path, so you would keep Mailgun or another inbound provider for it. The second is the raw SMTP relay. Loops sends over one HTTP API with Bearer auth, so an app that talks to smtp.mailgun.org today moves to API calls.

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 relay. Mailgun offers smtp.mailgun.org with per-domain SMTP credentials. Loops has no SMTP relay; it sends over the HTTP API only.

  • 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: "[email protected]",
  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, the part code can’t do

Some 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. 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. Recreate each template and publish it. Rebuild each Mailgun template in Loops, map every {{ }} Handlebars variable to a dataVariable of the same case-sensitive name, and publish. A draft cannot send: POST /v1/transactional returns 400 if the transactional email is not published.

  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?