Migrate from Customer.io to Loops

Customer.io and Loops share the same core model: people with attributes, events they trigger, contact segments, and messages sent on a schedule or on a trigger. One difference to plan for: Loops segments are dynamic on contact properties and email engagement, evaluated at send time, but they cannot use event-history conditions; reproduce those by writing the derived signal onto the contact as a property first, then segmenting on it. This guide maps each Customer.io concept to its Loops equivalent, swaps the API calls, and walks the cutover so deliverability stays intact.

Migration guides

Should you do this?

Migrate to Loops if your Customer.io usage is built on people, attributes, events, and event-triggered campaigns, and you want that same model in one place with a template editor and a small HTTP API. The event and workflow structure maps directly, so most of the work is renaming fields and re-pointing calls rather than redesigning your lifecycle.

Loops fits teams sending product, lifecycle, and transactional email from a software product. If your setup depends on features outside that shape, evaluate the fit against your own requirements first. This guide states the mapping and does not assume every Customer.io deployment is a match.

The one structural change to plan for up front: Customer.io messages built with Liquid templates and inline content become published Loops templates. Your code passes dynamic values as dataVariables, and there is no raw HTML at send time. Copy edits then happen in the Loops editor instead of a deploy, and the tradeoff is that template variable names and the values your code sends must stay in sync.

Swap the packages

Customer.io ships language SDKs and a REST API. Loops has an official JavaScript SDK. The package is named loops.

# Remove the Customer.io client you were using, for example:
npm uninstall customerio-node

# Add the Loops SDK:
npm

import { LoopsClient } from "loops"

const loops = new LoopsClient(process.env.LOOPS_API_KEY)

Loops also publishes SDKs for Nuxt (nuxt-loops), PHP (composer require loops-so/loops), and Ruby (gem install loops_sdk). If you call the HTTP API directly, the base URL is https://app.loops.so/api with Authorization: Bearer YOUR_API_KEY. All calls are server-side; browser calls hit CORS by design.

API mapping

Customer.io splits into two APIs: the Track API (Site ID plus Tracking API key, Basic auth, https://track.customer.io or the EU host) for identifying people and recording events, and the App API (bearer key, https://api.customer.io or the EU host) for transactional sends. Loops uses one bearer key for everything.

Customer.io

Loops

Identify or update a person: PUT /api/v1/customers/{id}

Upsert a contact: PUT /v1/contacts/update (SDK loops.updateContact)

Person {id} (your user id)

Loops userId or email (either works; no separate customer id)

Attributes on a person

Contact properties (camelCase; create with POST /v1/contacts/properties)

Track an event: POST /api/v1/customers/{id}/events

Send an event: POST /v1/events/send (SDK loops.sendEvent)

Event-triggered campaigns and broadcasts

Loops workflows (event triggers, timers, branches) or campaigns

Send transactional: POST /v1/send/email

POST /v1/transactional (SDK loops.sendTransactionalEmail)

transactional_message_id

transactionalId (from a published template)

message_data

dataVariables (case-sensitive, must match the template)

Data-driven segments

Audience segments (dynamic on properties and engagement) or mailing lists (GET /v1/lists)

Liquid templates

Published Loops templates

Two mapping details matter most. First, the Customer.io event name becomes the Loops eventName, and it must match a configured Loops trigger exactly for the workflow to fire. Second, Customer.io message_data becomes Loops dataVariables, and those names are case-sensitive against the published template. A missing required variable returns a 400.

Before and after

Identify or update a person.

// Before: Customer.io Track API
await cio.identify("user_123", {
  email: "[email protected]",
  firstName: "Alex",
  plan: "pro",
})
// After: Loops
await loops.updateContact({
  email: "[email protected]",
  userId: "user_123",
  properties: { firstName: "Alex", plan: "pro" },
})

Track an event that triggers a workflow.

// Before: Customer.io Track API
await cio.track("user_123", {
  name: "trial_started",
  data: { plan: "pro" },
})
// After: Loops (eventName must match the Loops trigger exactly)
await loops.sendEvent({
  userId: "user_123",
  email: "[email protected]",
  eventName: "trial_started",
  eventProperties: { plan: "pro" },
})

Send a transactional message.

// Before: Customer.io App API
await cioApp.sendEmail({
  transactional_message_id: "welcome",
  identifiers: { email: "[email protected]" },
  message_data: { firstName: "Alex", loginUrl: "https://app.example.com" },
})
// After: Loops (transactionalId points at a published template)
await loops.sendTransactionalEmail({
  transactionalId: "clfxxxxxx",
  email: "[email protected]",
  dataVariables: { firstName: "Alex", loginUrl: "https://app.example.com" },
})

The send endpoint is POST /v1/transactional. The template holds the subject, body, and design, and your code sends only the values that change.

Environment

# Before: Customer.io
CUSTOMERIO_SITE_ID=...          # Track API, from Settings -> API Credentials
CUSTOMERIO_TRACK_API_KEY=...    # Track API key
CUSTOMERIO_APP_API_KEY=...      # App API bearer key, for transactional sends
# After: Loops
LOOPS_API_KEY=...               # one key, from Settings -> API in the Loops dashboard

One Loops key replaces the separate Track and App credentials. Keep it server-side.

Dashboard setup, the part code can’t do

A few steps live in the dashboard, not the API.

Generate the API key under Settings -> API, then sanity check it. GET /v1/api-key returns { success: true, teamName } on a valid key.

Verify a sending domain before any send. Customer.io domain authentication (its CNAME and DKIM records) does not carry over. In Loops, sending-domain setup shows the SPF, DKIM, MX, and a default DMARC record to add in DNS, then verifies from the domain settings page. DNS can take up to an hour to propagate, so do this first. Sends only work from a verified domain.

Recreate each Customer.io message as a Loops template. Rebuild the Liquid content in the Loops editor, add every dynamic value as a data variable with the exact name and case your code sends, and publish it. Drafts cannot send, and the transactional send returns a 400 against an unpublished template. Find each transactionalId on the Transactional publish page or with GET /v1/transactional-emails.

Create contact properties for your Customer.io attributes. Use camelCase names and set the type (string, number, boolean, or date) with POST /v1/contacts/properties, or add them in the dashboard.

Create mailing lists for your segments and copy their ids. GET /v1/lists returns each list id, which you pass inside a mailingLists object like { [listId]: true } to subscribe a contact.

Cutover checklist

Migrate contacts. Loops has no public bulk-contact-import API. Export your Customer.io people to CSV and import them in the Loops dashboard under Audience -> import CSV. For a programmatic sync, loop POST /v1/contacts/create or PUT /v1/contacts/update per contact and stay within the rate limit.

Honor suppression from day one. Export your Customer.io suppressed and unsubscribed people and reflect that state in Loops before you send, either by setting subscribed: false on import or by keeping those addresses out of the mailed audience. Loops also auto-suppresses hard bounces and complaints going forward.

Run both providers in parallel and cut over per email type, not all at once. Move one workflow or transactional message to Loops, confirm delivery, then move the next. Keep the Customer.io campaign paused rather than deleted until its Loops equivalent is verified.

Mind the API limits. Loops allows 10 requests per second per team and returns 429 when you exceed it, so back off and retry during a bulk sync. Pass an Idempotency-Key header (up to 100 characters) on events and transactional sends so a retry does not double-send; a key reused within 24 hours returns 409.

Watch addToAudience on transactional sends. It defaults off. Set it to true only when a transactional recipient should also join the marketing audience, and leave it off for messages like password resets.

Keep template variable names and code in sync. Because copy lives in published templates, a renamed data variable in the editor must be matched in the code that sends it, or the send fails with a 400.

Common questions

Does my event-triggered setup carry over?

Can I keep sending Liquid or raw HTML from my code?

Will my unsubscribes carry over?

Do I need a separate key for transactional email like Customer.io's App API?