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.
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.
| 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; 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. 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:sendfor your app, created in API keys. - A second Unitpost key for the migration steps, with
contacts:write,suppressions:write,topics:writeandwebhooks:manage, saved asUNITPOST_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.
Convert the consent column
Loops' CSV has a
subscribedcolumn,truefor contacts who get campaigns. Add a column namedunsubscribedthat istruewheresubscribedisfalse, and deletesubscribed. Mappingsubscribedonto Unitpost's opt-out field would flip everyone's consent, and leaving it unmapped imports unsubscribed contacts as subscribed.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 withPOST /api/v1/suppressions. Doing this before the contact import also makes those contacts arrive unsubscribed.copy-suppressions.mjsimport { 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); }Import the CSV
In Contacts, choose Import CSV. Email, first and last name and your new
unsubscribedcolumn 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 "*".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.
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}/topicswith{ "topic_id": "top_…", "subscribed": true }. Unitpost has no bulk opt-in, by design.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 onenvelope, so both stay verified. Keep your single_dmarcrecord.Rebuild transactional emails as templates
Recreate each transactional email in the template editor. Every
dataVariableskey becomes a{{key}}placeholder filled fromtemplate.variables;first_name,last_nameandemailare reserved and fill from the contact. Set them totransactionaland publish.Switch the SDK
transactionalIdbecomes the template'stmpl_id anddataVariablesbecomestemplate.variables. Both APIs take anIdempotency-Keythat 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" } }, });Recreate webhooks
Loops signs
webhook-id.webhook-timestamp.bodywith HMAC-SHA256 and awhsec_secret; Unitpost signs the same three values, sent assvix-id,svix-timestampandsvix-signature. Rename the headers your verifier reads, use the new secret, and read the event name fromtypeinstead ofeventName.Test, move traffic, then cancel
Send each template with
POST /api/v1/email/templates/{id}/test-sendand firePOST /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
| 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
envelopesubdomain and its DKIM records once no mail goes through Loops. Keep_dmarcand 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?
How do I keep Loops unsubscribes?
Can Unitpost run my Loops workflows?
I send through Loops SMTP. What changes?
Is Loops' editor better than Unitpost's?
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.