Migrate from Customer.io to Loops
Moving from Customer.io to Loops means mapping contacts, properties, events, and messages, then testing the rebuilt workflows before switching traffic. Loops segments use contact properties and email engagement, evaluated at send time. They cannot use event-history conditions, so calculate those signals in your app and write them to contact properties.
Should you do this?
Migrate to Loops if your Customer.io usage centers on contacts, attributes, events, and lifecycle email, and the Loops workflow model meets your requirements. Map each trigger, branch, timing rule, and exit condition before moving traffic. Similar concepts do not guarantee identical behavior.
Loops supports marketing, lifecycle, and transactional email for software products. List any Customer.io features you depend on and verify their equivalents before committing to the migration.
Rebuild Customer.io Liquid messages as 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.
Install the Loops SDK
Customer.io ships language SDKs and a REST API. Loops has an official JavaScript SDK. The package is named loops. Keep the Customer.io SDK installed until no send or rollback path depends on it.
npm install loopsimport { LoopsClient } from "loops"
const apiKey = process.env.LOOPS_API_KEY
if (!apiKey) throw new Error("Missing LOOPS_API_KEY")
const loops = new LoopsClient(apiKey)Loops also publishes official SDKs for Go, 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 dashboardOne Loops key replaces the separate Track and App credentials. Keep it server-side.
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.
Generate the API key under Settings -> API and verify 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. Records can take up to 72 hours to propagate, though often only a few hours, so do this first. Sends only work from a verified domain.
Migrate each Customer.io message 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. Create campaign drafts with POST /v1/campaigns and update their email messages the same way.
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.
Rebuild dynamic audience rules as segments. Use mailing lists for subscription preferences and preserve each person’s opt-outs. Do not subscribe every imported contact to every list. Derive event-history conditions in your app and sync the resulting contact properties before evaluating the segment. GET /v1/lists returns each list id, which you pass inside a mailingLists object like { [listId]: true } to subscribe a contact.
Reconcile contact and workflow state
Save a dated export and a mapping for stable user IDs, email addresses, property names and types, global unsubscribes, list preferences, and bounce or complaint suppressions. Compare records by user ID and investigate missing or duplicate identities. Matching total contact counts is not enough.
Keep a durable suppression record in your app and apply it before enabling sends. A marketing unsubscribe and a delivery suppression serve different purposes. Do not assume setting subscribed to false blocks transactional mail. Preserve delivery suppressions in your send routing until the equivalent provider behavior is verified, and ensure ordinary profile syncs cannot silently resubscribe someone.
Record each person’s last completed lifecycle step and the next eligible action. Decide whether existing journeys will finish in Customer.io or enter a specific rebuilt path in Loops. Do not assume active timers or historical events transfer. Backfill derived properties without replaying old events into live triggers.
During CSV backfill, leave Trigger workflows off. For API or integration backfills, use an explicit eligibility gate and test it before syncing the audience. Reconcile changes made after the snapshot, especially new opt-outs, account upgrades, and email-address changes, before opening the gate.
Run a pilot with explicit acceptance checks
Choose a small cohort that covers the states your workflows use. Include new and existing contacts, a changed email address, an upgraded account, an unsubscribed contact, a list opt-out, and a suppressed address. Use controlled test records for negative cases. Write the expected recipient, message, timing, and exclusion before each test.
Confirm that every pilot identity maps to one intended contact, required properties have the expected types and values, and every opt-out remains excluded from the relevant marketing sends. Any unexpected resubscription, duplicate contact, or missing exclusion blocks expansion.
Send one fresh qualifying event and confirm the intended path. Test a repeated event, a non-qualifying event, and an upgrade during a delay. Check that exit conditions stop obsolete reminders and that the backfill caused no unintended enrollment.
Compare your application’s intended sends with provider activity using a durable message ID. Confirm one intended send, the correct template and variables, valid links, and the expected suppression outcome. An accepted API request is not proof of inbox delivery. Inspect actual messages in inboxes you control as well as delivery and bounce events.
Reconcile every pilot mismatch before expansion. Set delivery-error and latency limits from your own baseline and message deadlines. Any duplicate send, wrong recipient, broken critical link, or suppression failure is a stop condition. Widen the cohort only after the full delay paths have been exercised.
Prepare rollback before switching traffic
Keep a routing switch per message type and a durable send ledger containing the business event ID, recipient, template, provider, and send status. A provider-specific idempotency key cannot prevent a second provider from sending the same business message.
If a stop condition occurs, halt new routing to the affected Loops path. Inspect both providers’ accepted and queued work before restoring the previous route. Treat a timeout as an unknown outcome until reconciled. Retry only messages confirmed unsent and still useful, and retain opt-outs and state changes recorded during the pilot.
Do not use Pause as a queue purge. Loops queues new contacts for up to 24 hours while a workflow is paused and enters them when it resumes, so inspect pause and resume behavior before restarting. Keep the old provider available until the agreed observation window and delayed-message checks have passed.
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.
Export Customer.io marketing preferences and delivery suppressions before you send. Preserve marketing unsubscribes with subscribed: false and retain per-list opt-outs. Keep delivery-suppressed addresses blocked in your send routing until equivalent suppression is verified in Loops. A marketing unsubscribe does not block transactional mail. Loops suppresses new hard bounces and complaints automatically.
Keep both providers available, but route each production email type to one provider at a time. Pilot a small cohort, validate its sends, then expand. Keep the old configuration for rollback and reconcile queued messages before either provider resumes.
Mind the API limits. Loops allows 10 requests per second per team, and content API endpoints for campaigns, transactional emails, email messages, and workflows are limited to 60 requests per 60 seconds. Both limits return 429 when exceeded, 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?