Migrate from Loops to Unitpost

Complex migrationLoops has no API that lists contacts, so contacts, their consent and their suppressions come over by CSV export and scripts; the importer only brings mailing lists, as topics.

LoopsUnitpost
TopicsPartly imports
ContactsRebuild
Send history and analyticsStays behind

What comes with you

To leave Loops, export your audience as CSV and import it into Unitpost, because Loops' API cannot list contacts. Convert Loops' `subscribed` column into an `unsubscribed` column first, or every unsubscribed contact arrives subscribed. The Migration Assistant then turns your mailing lists into opt-in topics. Suppressions, list membership, emails, the sending domain and webhooks are rebuilt by hand; workflows, events and send history stay at Loops.

DataResultWhat happens
TopicsPartly importsEach mailing list becomes an opt-in topic (default_opt_in: false). Who was on which list does not come with it, since no contacts import.
ContactsRebuildNot imported: Loops has no endpoint that lists contacts. Export the audience as CSV and import it under Contacts; custom properties map to contact fields.
Unsubscribes and consentRebuildLoops stores consent as subscribed. Unitpost's CSV import reads an opt-out column and does not map a subscribed header, so convert it before upload (step 1).
Suppressions (bounces and complaints)RebuildLoops suppresses hard bounces and complaints but only answers per address (GET /api/v1/contacts/suppression). Step 2 checks your export and copies the suppressed ones.
SegmentsRebuildAudience segments are not read. Recreate them as dynamic segments on contact fields.
TemplatesRebuildNot read. Loops emails are built in its editor or LMX rather than HTML, so rebuild each in Unitpost's editor.
Sending domainsRebuildNot read. Add the domain in Domains. Loops' SPF and MX sit on the envelope subdomain and Unitpost's on unitpost.<your domain>, so both can be live.
WebhooksRebuildNot read: Loops webhooks live in its settings page. Create endpoints with POST /api/v1/webhooks.
API keysRebuildLoops keys stay at Loops. Create Unitpost API keys instead.
EventsRebuildEvents sent to POST /api/v1/events/send trigger Loops workflows; Unitpost has no launched equivalent. Send the email from the code that fires the event.
Send history and analyticsStays behindCampaign, workflow and transactional metrics stay at Loops.
Automations (workflows)Stays behindWorkflows stay at Loops. Unitpost automations are not launched yet, so send those emails from your code until they are.

Before you start

  • A full audience CSV from Loops: in Audience, open the ••• menu in the filter box and choose Download.
  • Any Loops API key (Settings → API); the importer only reads mailing lists, and step 2 uses it for suppression checks.
  • A Unitpost API key with emails:send for your app, created in API keys.
  • A second Unitpost key for the migration steps, with contacts:write, suppressions:write, topics:write and webhooks:manage, saved as UNITPOST_MIGRATION_KEY. Keep it out of your app and revoke it once the move is done.
  • Access to your DNS host.

The cut-over plan

Loops keeps sending until the last step, so there is no gap in delivery.

  1. Convert the consent column

    Loops' CSV has a subscribed column, true for contacts who get campaigns. Add a column named unsubscribed that is true where subscribed is false, and delete subscribed. Mapping subscribed onto Unitpost's opt-out field would flip everyone's consent, and leaving it unmapped imports unsubscribed contacts as subscribed.

  2. Copy suppressed addresses

    Loops has no list of suppressed contacts. Check each exported address with Loops' GET /api/v1/contacts/suppression?email= and add the suppressed ones with POST /api/v1/suppressions. Doing this before the contact import also makes those contacts arrive unsubscribed.

    copy-suppressions.mjs
    import { Unitpost } from "unitpost";
    
    const unitpost = new Unitpost(process.env.UNITPOST_MIGRATION_KEY);
    const emails = readEmailColumn("loops-audience.csv"); // your CSV parser
    
    // Loops answers one address at a time and rate-limits its API. A failed check
    // stops the copy: a list with gaps would let you re-mail a bounce or complaint.
    const suppressed = [];
    for (const email of emails) {
      const url = "https://app.loops.so/api/v1/contacts/suppression?email=" + encodeURIComponent(email);
      for (let attempt = 1; ; attempt++) {
        const res = await fetch(url, { headers: { Authorization: "Bearer " + process.env.LOOPS_API_KEY } });
        if (res.ok) {
          if ((await res.json()).isSuppressed) suppressed.push(email);
          break;
        }
        if ((res.status === 429 || res.status >= 500) && attempt < 5) {
          const wait = Number(res.headers.get("retry-after")) || 2 ** attempt;
          await new Promise((r) => setTimeout(r, wait * 1000));
          continue;
        }
        throw new Error(`Suppression check failed for ${email}: HTTP ${res.status}. Fix it and rerun.`);
      }
    }
    
    // Up to 1,000 per call. reason "import" makes the contact import mark them unsubscribed.
    for (let i = 0; i < suppressed.length; i += 1000) {
      const { error } = await unitpost.suppressions.create({
        emails: suppressed.slice(i, i + 1000),
        reason: "import",
      });
      if (error) throw new Error(error.message);
    }
  3. Import the CSV

    In Contacts, choose Import CSV. Email, first and last name and your new unsubscribed column map on their own; check the mapping, and import other properties as custom fields. From a terminal: unitpost contacts imports create --file loops-audience.csv --custom-column "*".

  4. Import mailing lists as topics

    Paste the Loops key in Settings → Migration. Each list becomes an opt-in topic, so nobody gets mail scoped to it yet.

  5. Restore list membership

    These people opted in at Loops, so record it here. Filter the Loops audience by each list, download it, and for every address call PUT /api/v1/contacts/{email}/topics with { "topic_id": "top_…", "subscribed": true }. Unitpost has no bulk opt-in, by design.

  6. Add and verify the domain

    Add the sending domain in Domains and publish the records shown: MX and SPF on unitpost.<your domain> and a DKIM TXT under _domainkey. Loops keeps its records on envelope, so both stay verified. Keep your single _dmarc record.

  7. Rebuild transactional emails as templates

    Recreate each transactional email in the template editor. Every dataVariables key becomes a {{key}} placeholder filled from template.variables; first_name, last_name and email are reserved and fill from the contact. Set them to transactional and publish.

  8. Switch the SDK

    transactionalId becomes the template's tmpl_ id and dataVariables becomes template.variables. Both APIs take an Idempotency-Key that lasts 24 hours.

    send.ts
    // Before: Loops
    import { LoopsClient } from "loops";
    const loops = new LoopsClient(process.env.LOOPS_API_KEY);
    await loops.sendTransactionalEmail({
      transactionalId: "clfq6dinn000yl70fgwwyp82l",
      email: "user@example.com",
      dataVariables: { loginUrl: "https://app.acme.com/login" },
    });
    
    // After: Unitpost
    import { Unitpost } from "unitpost";
    const unitpost = new Unitpost(process.env.UNITPOST_API_KEY);
    const { data, error } = await unitpost.email.send({
      from: "Acme <hi@mail.acme.com>",
      to: "user@example.com",
      template: { id: "tmpl_123", variables: { loginUrl: "https://app.acme.com/login" } },
    });
  9. Recreate webhooks

    Loops signs webhook-id.webhook-timestamp.body with HMAC-SHA256 and a whsec_ secret; Unitpost signs the same three values, sent as svix-id, svix-timestamp and svix-signature. Rename the headers your verifier reads, use the new secret, and read the event name from type instead of eventName.

  10. Test, move traffic, then cancel

    Send each template with POST /api/v1/email/templates/{id}/test-send and fire POST /api/v1/webhooks/{id}/test. Move transactional mail first, then campaigns, and cancel Loops once the checks below are done.

Name changes

Same ideas, new names

LoopsUnitpostNote
AudienceContacts
Mailing listTopic (opt-in)
Audience segment or filterSegment (dynamic)
Contact propertyContact field
subscribed: falseunsubscribed: true
Transactional email (transactionalId)Template (tmpl_ id)
dataVariablesTemplate variables
Workflow (loop)AutomationsNot launched yet.
Preference CenterUnsubscribe preference page

API calls

Loops APIUnitpost APINote
POST /api/v1/transactionalPOST /api/v1/email with template
POST /api/v1/contacts/createPOST /api/v1/contacts
PUT /api/v1/contacts/updatePATCH /api/v1/contacts/{id or email}
GET /api/v1/contacts/find?email=GET /api/v1/contacts/{email}
POST /api/v1/contacts/deleteDELETE /api/v1/contacts/{id or email}
GET /api/v1/listsGET /api/v1/email/topics
GET /api/v1/contacts/suppressionGET /api/v1/suppressions/{email}
DELETE /api/v1/contacts/suppressionDELETE /api/v1/suppressions/{email}No removal quota here, but complaint rows cannot be removed.
POST /api/v1/events/sendNo launched equivalent

SMTP settings

Loops SMTPUnitpost SMTPNote
smtp.loops.sosmtp.unitpost.com
Port 587587, 2587, 25 (STARTTLS) or 465, 2465 (implicit TLS)
Username loopsUsername unitpost
Password: Loops API keyPassword: Unitpost API key with emails:send
Body: a JSON payload with transactionalIdA normal MIME messageSubject and body come from the message itself.

Webhook events

Loops eventUnitpost eventNote
contact.createdcontact.created
contact.unsubscribedcontact.updated
contact.deletedcontact.deleted
contact.mailingList.subscribedtopic.subscribed
contact.mailingList.unsubscribedtopic.unsubscribed
campaign.email.sent, loop.email.sent, transactional.email.sentemail.sent
email.deliveredemail.delivered
email.softBouncedemail.delivery_delayedA soft bounce that gives up is email.bounced.
email.hardBouncedemail.bounced
email.opened, email.clickedSame names
email.spamReportedemail.complained
email.unsubscribed, email.resubscribedcontact.updated

Before you cancel Loops

  • Download a fresh audience CSV, plus one per mailing list; Loops has no API to list contacts later.
  • Re-run the suppression check on addresses added since your first export.
  • Note every workflow and the event that triggers it, so the emails they sent are covered in your code.
  • Delete Loops' records on the envelope subdomain and its DKIM records once no mail goes through Loops. Keep _dmarc and Unitpost's records.
  • Turn off Loops webhooks and revoke its API keys.

Facts about Loops checked 2026-11-02. Providers change their APIs and plans; check their docs before you rely on a detail.

Questions about leaving Loops

Why doesn't the importer bring my Loops contacts?

Loops' API can create, update, find and delete one contact, but has no endpoint that lists them. The CSV export is the only full copy, so contacts come over through it.

How do I keep Loops unsubscribes?

Turn the `subscribed` column into an `unsubscribed` column before you import (step 1). Unsubscribed contacts then arrive opted out of marketing and still get transactional mail.

Can Unitpost run my Loops workflows?

Not yet. Unitpost automations are behind a flag and not launched, and workflows are the core of Loops. If lifecycle sequences are why you use Loops, Unitpost is not a replacement for them today.

I send through Loops SMTP. What changes?

Loops SMTP takes a JSON payload naming a transactional email; Unitpost SMTP takes the real message, so your app sends its own subject and body. Host, username and password change as in the SMTP table.

Is Loops' editor better than Unitpost's?

Loops' editor is more polished. Unitpost's gives a visual builder and code mode over the same template, which matters more when developers and marketers edit the same email.

Leaving Loops?

Preview the migration on the free tier

Verify a domain, paste a read-only key, and see what would come across before anything is written.