---
title: "Migrate from Loops to Unitpost"
description: "Leave Loops in order: export the audience CSV, turn subscribed into an unsubscribed column, copy suppressions, import lists as topics, rebuild emails."
url: https://www.unitpost.com/migration/loops
section: Migration
updated: 2026-11-02
---
# Migrate from Loops to Unitpost

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.

**Complex migration.** Loops 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.

## What comes with you

| Data | Result | What happens |
| --- | --- | --- |
| Topics | Partly imports | Each 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. |
| Contacts | Rebuild | Not imported: Loops has no endpoint that lists contacts. Export the audience as CSV and import it under [Contacts](/dashboard/contacts); custom properties map to contact fields. |
| Unsubscribes and consent | Rebuild | Loops 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) | Rebuild | Loops 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. |
| Segments | Rebuild | Audience segments are not read. Recreate them as dynamic segments on contact fields. |
| Templates | Rebuild | Not read. Loops emails are built in its editor or LMX rather than HTML, so rebuild each in Unitpost's editor. |
| Sending domains | Rebuild | Not read. Add the domain in [Domains](/dashboard/email/domains). Loops' SPF and MX sit on the `envelope` subdomain and Unitpost's on `unitpost.<your domain>`, so both can be live. |
| Webhooks | Rebuild | Not read: Loops webhooks live in its settings page. Create endpoints with `POST /api/v1/webhooks`. |
| API keys | Rebuild | Loops keys stay at Loops. Create Unitpost API keys instead. |
| Events | Rebuild | Events 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 analytics | Stays behind | Campaign, workflow and transactional metrics stay at Loops. |
| Automations (workflows) | Stays behind | Workflows 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](/dashboard/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

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

```ts
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](/dashboard/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](/dashboard/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](/dashboard/email/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.

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

## Same ideas, new names

| Loops | Unitpost | Note |
| --- | --- | --- |
| Audience | Contacts |  |
| Mailing list | Topic (opt-in) |  |
| Audience segment or filter | Segment (dynamic) |  |
| Contact property | Contact field |  |
| `subscribed: false` | `unsubscribed: true` |  |
| Transactional email (`transactionalId`) | Template (`tmpl_` id) |  |
| `dataVariables` | Template `variables` |  |
| Workflow (loop) | Automations | Not launched yet. |
| Preference Center | Unsubscribe preference page |  |

## API calls

| Loops API | Unitpost API | Note |
| --- | --- | --- |
| POST /api/v1/transactional | POST /api/v1/email with `template` |  |
| POST /api/v1/contacts/create | POST /api/v1/contacts |  |
| PUT /api/v1/contacts/update | PATCH /api/v1/contacts/{id or email} |  |
| GET /api/v1/contacts/find?email= | GET /api/v1/contacts/{email} |  |
| POST /api/v1/contacts/delete | DELETE /api/v1/contacts/{id or email} |  |
| GET /api/v1/lists | GET /api/v1/email/topics |  |
| GET /api/v1/contacts/suppression | GET /api/v1/suppressions/{email} |  |
| DELETE /api/v1/contacts/suppression | DELETE /api/v1/suppressions/{email} | No removal quota here, but complaint rows cannot be removed. |
| POST /api/v1/events/send | No launched equivalent |  |

## SMTP settings

| Loops SMTP | Unitpost SMTP | Note |
| --- | --- | --- |
| smtp.loops.so | smtp.unitpost.com |  |
| Port 587 | 587, 2587, 25 (STARTTLS) or 465, 2465 (implicit TLS) |  |
| Username `loops` | Username `unitpost` |  |
| Password: Loops API key | Password: Unitpost API key with `emails:send` |  |
| Body: a JSON payload with `transactionalId` | A normal MIME message | Subject and body come from the message itself. |

## Webhook events

| Loops event | Unitpost event | Note |
| --- | --- | --- |
| contact.created | contact.created |  |
| contact.unsubscribed | contact.updated |  |
| contact.deleted | contact.deleted |  |
| contact.mailingList.subscribed | topic.subscribed |  |
| contact.mailingList.unsubscribed | topic.unsubscribed |  |
| campaign.email.sent, loop.email.sent, transactional.email.sent | email.sent |  |
| email.delivered | email.delivered |  |
| email.softBounced | email.delivery_delayed | A soft bounce that gives up is `email.bounced`. |
| email.hardBounced | email.bounced |  |
| email.opened, email.clicked | Same names |  |
| email.spamReported | email.complained |  |
| email.unsubscribed, email.resubscribed | contact.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.

## FAQ

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

## Related

- [Migrate from Resend to Unitpost](https://www.unitpost.com/migration/resend): Leave Resend in order: copy its suppression list, import contacts, topics and templates, re-verify DNS, swap the SDK and re-key svix webhooks.
- [Migrate from SendGrid to Unitpost](https://www.unitpost.com/migration/sendgrid): Leave SendGrid in order: import contacts and suppressions, copy unsubscribe-group opt-outs, re-verify DNS, port dynamic templates, swap the webhook verifier.
- [Migrate from Postmark to Unitpost](https://www.unitpost.com/migration/postmark): Leave Postmark in order: import every server's suppressions, templates and webhooks, keep broadcast unsubscribes off receipts, re-verify DNS, swap the client.
