Migrate from Mailgun to Loops

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

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

Install the official loops package. Keep the Mailgun 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 each send path and its operational requirements. Keep a separate provider for any inbound processing that remains in use.

  • 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. Mailgun mailing-list members can store an address, subscription state, and custom vars. Map the needed member data to Loops contact properties, and reconcile the same address across lists before upserting it. 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 map each contact’s actual permission into mailingLists: { [listId]: true } or false.

  • Suppressions. Export Mailgun’s per-domain /v3/{domain}/bounces, /unsubscribes, and /complaints. Preserve marketing opt-outs separately from delivery blocks. Follow the cutover checklist below before enabling either send path.

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

Sending and contact-sync examples

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

LOOPS_API_KEY=
LOOPS_RESET_TEMPLATE_ID=

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

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