Skip to main content
Customer.io is a customer data platform. It has profiles, attributes, events, and objects across two separate APIs. Its messages use Liquid templating, which Brew renders natively too. This guide assumes your Brew brand and sending domain are set up. If not, verify your domain first. DNS changes can take up to 72 hours. Customer.io also uses Entri for automatic domain setup, the same DNS-connection partner Brew uses. That part of the process should look familiar.
Looking to go the other way, designing in Brew and sending from Customer.io? That Customer.io integration is coming soon.
Rule for the whole migration: only one platform sends live email to a given contact at a time.

Concepts

Every marketing recipient must have opted in to hear from you. Importing or syncing contact data into Brew is not permission to email it. See What You Can Send.
Go to People and open the Export to CSV menu in the top right. Choose all attributes, displayed attributes, or specific attributes. Filter to a segment first if you only want a subset. Customer.io gives you one CSV with everyone in it, so the status split happens at import. The suppression list is a separate export. Go to Workspace Settings → Workspace Suppressions, export the CSV, then download it from the Exports page. The CSV is hashed, not a plain list. Its columns are cio_id, id_sha256 and email_sha256, with id and email hashed as lowercase SHA-256. Hash the email values in your profile export the same way to match rows. This list covers people deleted and suppressed for GDPR. Unsubscribes come through the unsubscribed column of your profile export. Bounces and spam complaints sit in Workspace Settings → Email → Suppression List. That list has no export button, so pull it with Customer.io’s ESP Suppression API if you need it. Customer.io has already deleted the people on the Workspace Suppressions list, so they aren’t in your profile export. Keep that hashed list so your backend never re-adds them. Hash an address the same way before you create a contact or fire a trigger for it. Brew’s importer also never re-subscribes a contact who previously unsubscribed. Keep any opt-in date and source columns in the export. Bring Your Consent Records covers carrying them over.

2. Map Your CSV

Notes:
  • Customer.io reserves a set of fixed-name attributes that won’t map 1:1 to Brew’s own reserved names, including id, cio_id, email, phone, created_at and unsubscribed. Rename collisions before mapping.
  • Per-topic preferences live in a separate attribute, cio_subscription_preferences. Export it too if you use subscription topics. Someone who opted out of one topic imports as subscribed to all marketing, unless you mark them Subscribed: false or import them into the matching domain’s unsubscribe list.
  • Don’t import unsubscribed as it is. Brew reads opt-outs only from a Subscribed column, so an unsubscribed column becomes a plain custom property and those people import as subscribed.
  • created_at is skipped, because Brew manages its own creation date. Rename it, for example to cioCreatedAt, to keep the date.
  • Dates: day-first dates such as 03/04/2026 (3 April) import correctly when you pick day first at the mapping step, and YYYY-MM-DD needs no choice. See date formats.
For how an import handles empty cells, duplicate rows, and columns Brew manages such as Created At, see Add Contacts. Custom properties can’t use the names of Brew’s built-in contact fields, listed in Custom Properties.

3. Import into Brew

Audience → Add contacts → CSV Upload. Map columns, review, then import. Tick the validation checkbox first if the list hasn’t been checked recently. It costs credits, which is worth it before warming up a new domain. Validate Contacts has the cost and Add Contacts the full import behavior. A contact’s Subscribed status says Brew may email them. A consent record says how and when they agreed. Carry both over.
  • Export the evidence. Include any opt-in date or subscription-preference attribute your Customer.io profiles carry. Keep those columns as custom properties.
  • In the app, tick the consent checkbox at the review step. Brew records consent once for the whole file, dated the day you import.
  • To keep each person’s original opt-in date, import over the API instead, with a consent record per contact. See Add Contacts for the consent fields.

4. Rebuild Segmentation

Data-driven segments are the one structural difference to plan for. Customer.io segments can filter directly on event history, including page events. In Brew, write that signal onto the contact as a custom property, then build the Audience on it. Lists and Event-Based Segments has the details. Before cutover, spot-check that a recreated Audience’s membership count is in the right ballpark against the original segment. A wrong translation can undercount or overcount who gets an email, and that isn’t always visible in the UI. Manual segments don’t have a direct equivalent. Rebuild each one as a custom property plus an Audience filtered on it. If people joined a segment themselves, decide how to honor that choice before your first send.

5. Rebuild Templates and Merge Tags

Open Emails in the sidebar, then use the Import tools on the right side of the canvas. See Importing existing designs. Set your brand identity once: logo, colors, fonts, and voice pulled from your site. Every rebuilt template inherits it automatically, which beats hand-porting markup with Liquid still embedded in it.

Translate Merge Tags

Brew renders standard Liquid on every workspace, so most templates need only renamed fields:
  • {{ customer.first_name | default: "there" }} becomes {{ customer.firstName | default: "there" }}. Brew field names are camelCase, and a snake_case name renders empty.
  • {% if %}...{% else %}...{% endif %} conditionals, {% for %} loops, and filters render at send time.
  • {{ trigger.order.total }} reads the nested payload you send with the fire, no flattening required.
  • In an event-triggered campaign, {{ event.plan }} becomes {{ trigger.plan }}. Brew has no event. namespace.
  • Customer.io-only tags and filters, such as add_day, format_number, unsubscribe_url and view_in_browser_url, need rewriting. Brew reads an unknown filter name as fallback text.
Brew’s own shorthand works alongside it. {{ firstName | there }} is a merge tag with a fallback, not a filter chain. Still needing a rebuild, because the data model behind them has no Brew equivalent:
  • Journey attributes ({{ journey.attribute }}), temporary values scoped to a single workflow run
  • Object and relationship loops over Customer.io’s Objects ({% for class in objects.online_classes %}). Loops over payload data you send yourself work fine
  • Snippets ({{ snippets.footer }}), reusable blocks, which you rebuild as standing instructions on your brand instead

6. Rebuild Automations

Brew has no exit conditions or re-entry settings like Customer.io’s. A contact update doesn’t remove anyone, and a repeat event starts a second run. To exit people who no longer match, add a Filter after each Wait, as Conditions After a Wait shows. Test-fire before publishing, and leave every rebuilt automation unpublished until cutover day. Build an Automation covers the trigger setup and the test step.

7. Swap the API Calls in Your Code

Customer.io splits its API in two. The Track API uses a Site ID and API key over HTTP basic auth to identify people and record events. The App API uses a bearer key for transactional sends and API-triggered broadcasts. Brew uses one bearer key for everything, with no split credentials to manage. Identify and track. These calls keep contacts current and power every automation you rebuild. Before, with Customer.io’s Track API:
After, with Brew:
An identify call covers every app user, so the contact is created with subscribed: false. For people who opted in to marketing, send subscribed: true with a consent record instead. subscribed: true applies only to a new contact. Brew refuses it for anyone who opted out, and Unsubscribes says how to retry. The pattern stays the same whatever kind of email the automation sends. What changes is which automation receives the event, not this code. Direct sends. Customer.io has two direct-send methods, and both move to the same fire endpoint. Transactional messages become triggers with one-step automations. In Brew, a transactional email is an automation. Create a trigger once, declaring the payload fields, and wire it to a one-step automation. Its Send Email node sends from a domain with a transactional sending purpose. That domain purpose is what makes the send transactional: no unsubscribe link required, and delivery reaches unsubscribed contacts. Publish it once, then your code fires it by trigger id. transactional_message_id maps to the triggerEventId you fire, and message_data becomes payload, nested JSON and all. Before, with Customer.io’s App API:
After, with Brew:
The recipient rides in the payload: the trigger’s email field is the contact key, so there’s no separate to. Templates read the payload as {{ trigger.order.total }} and contact fields as {{ customer.firstName }}. Those are the same namespaces your Customer.io templates already use. GET /v1/automations/triggers/{triggerEventId} returns the trigger’s payloadSchema, the exact contract every fire is validated against. Diff it against what your code sends, then call GET /v1/automations/triggers/{triggerEventId}/readiness to preflight the wiring without firing. QA first with a test run of the automation, which applies the same payload rules as a live fire. API-triggered broadcasts use the trigger fire endpoint instead:
After, with Brew, a per-recipient trigger becomes a fire call against whichever automation you built for it:
Derive the Idempotency-Key from something stable about the action, like the user id above. A key built from a timestamp changes on every retry, which defeats the point and lets a retry send twice. See Idempotency. One exception: a broadcast aimed at a whole saved segment isn’t a per-recipient event. Use a manual-audience automation run, POST /v1/automations/{automationId}/run, or send a one-off Email to an Audience instead. The fire endpoint is for per-recipient triggering. Full request shapes are in the API reference. This section shows the shape of the migration, not a copy-paste replacement for your code.

8. Cut Over

Move in this order:
  1. Rebuild while Customer.io keeps sending. Import contacts and rebuild segments, templates, and automations. None of it touches live sending. Keep every Brew automation unpublished.
  2. Check your segments. Confirm each recreated Audience’s membership count against the original. See Step 4.
  3. Refresh opt-outs. Right before your first Brew send, export Customer.io’s new unsubscribes and bounces and import them into Brew as Subscribed: false. That stops marketing only. Transactional sends still reach these contacts, so don’t fire transactional triggers for addresses that hard-bounced or complained. Repeat before each type you move, until Customer.io stops sending. Anyone who opted out during the rebuild stays opted out.
  4. Warm up with the first type you move. Pause that type on Customer.io first, so nobody gets it from both. Send to your most engaged contacts first and ramp over several days. Gradual send does the ramping for you. No history means no reputation, even on a verified domain.
  5. Move one type at a time. Broadcasts first, then automations. For each automation, in order: pause it on Customer.io, publish the Brew version, then point its signup forms, event tracking, or inbound integrations at Brew. Watch each in analytics before you move the next.
  6. Move transactional messages last. Before you move any, add a separate transactional-purpose subdomain (see Transactional Email). A fire for a new address creates a subscribed contact, so only send marketing to people who opted in. They are the highest-stakes sends. Cut them over one at a time, and test-run each automation with a real payload before you point production code at its trigger.
  7. Keep Customer.io as a fallback for a couple of weeks, then close it down once every message type has a clean week in Brew.
Never leave the same message type live in both places at once.

From an Agent or Code

Steps 3 to 5 also run from an AI assistant connected over MCP. Ask in plain English, one step at a time:
“Import subscribed.csv, then unsubscribed.csv, as separate batches, and record the consent source as import.”
“Create an audience of subscribed contacts whose plan is pro, and tell me how many it holds.”
“Import this HTML as an editable email called Newsletter.”
Check each count against the old platform before you move on. From code:
  • Contacts. POST /v1/contacts/import-csv, or brew-cli contacts import-csv --file contacts.csv, once per file. Add Contacts covers limits and the consent record.
  • Segments. POST /v1/audiences with a name and filters, or brew-cli audiences create.
  • Templates. POST /v1/emails/import with the format and content. Import is free and uses no AI.
Step 6’s flows follow Build an Automation.

Quick Reference

Need Help?

Our team is ready to support you at every step of your journey with Brew. Choose the option that works best for you:

Search Documentation

Type in the “Ask any question” search bar at the top left to instantly find relevant documentation pages.

Ask ChatGPT or Claude

Click here to open the Copy page menu at the top of this page, then send the page to ChatGPT, Claude, or your editor.