Migrating from Marketing Cloud or HubSpot, and exporting your workspace

Two directions, one format. A workspace bundle is a whole company as one document (brands, forms, sources, goals, blocks, brand kits, templates, lists, segments, journeys, broadcast drafts, and when a migration brings them, contacts and suppressions). Coming in, a migration adapter reads the files your old system lets you download and writes a bundle; going out, an export gives your workspace back as a bundle, and a contacts export gives every person with their consent ledger. Every bundle goes through the same plan, apply and go-live approval.

Migrating in

What to download from your old system

You need no API access: these are files any admin can export. Put them in one folder, in these sub-folders (the importer reads a file by its folder first, then its name and headers).

Marketing Cloud (MCE) (system: "mce")

FileWhere to get itWhat it becomes
emails/<name>.htmlContent Builder: open the email, copy its HTML (Code view) into a file named after the emailan HTML template; AMPscript converted (below); the <title> is the subject
Data Extensions/<name>.csvContact Builder → Data Extensions → Export (or a data extract)contacts: known columns to fields, SubscriberKey to mce_subscriber_key, the rest to properties
all-subscribers.csvEmail Studio → Subscribers → All Subscribers → ExportUnsubscribed: a withdrawal of all the brand's email (*); Held / Bounced: a suppression (hard bounce); Active: see below
publication-lists/<list name>.csvEmail Studio → Subscribers → Publication Lists → each list → Exportone subscription type per list (added to the brand); Active: granted; Unsubscribed: withdrawn; Held / Bounced: suppression
tracking/Opens.csv, tracking/Clicks.csvTracking extract (Automation Studio data extract, Tracking Extract)each person's newest open and click (see engagement dates)
journeys/<name>.jsonJourney Builder: the journey's JSON (the interaction definition; your MCE admin or a partner tool can save it)a draft journey (below)

Active in All Subscribers says a person may be emailed, not which kind of email they agreed to: publication lists say that. Active subscribers who are on no publication list come over without consent, and the report says how many; pass options.allSubscribersType: "<type>" to grant them the type they agreed to.

Marketing Cloud dates are read month first (9/14/2026 8:02:11 AM), as UTC.

HubSpot (system: "hubspot")

FileWhere to get itWhat it becomes
emails/<name>.htmlMarketing → Email → the email → Actions → Export (HTML)an HTML template; HubL converted (below)
contacts.csv (any CSV not in lists/)Contacts → Export, with All propertiescontacts: known columns to fields (Company Name → company, Lifecycle Stage → lifecycle_stage, Record ID → hubspot_record_id), the rest to properties
lists/<list name>.csvContacts → Lists → the list → Exporta static list of those people; they become contacts without consent unless the contacts export says otherwise. An active list's rules are not kept: make a segment for them

Subscription columns in the contacts export become consent in the brand's subscription types (each type a column names is added to the brand):

ColumnValueBecomes
Unsubscribed from all emailtruewithdrawn, all the brand's email (*)
Opted out of email: <type>truewithdrawn, that type
Subscription status: <type> (or Subscription: <type>)Subscribed / Opted ingranted, that type
Unsubscribed / Opted outwithdrawn, that type
Email hard bounce reasonany valuesuppression (hard bounce)
Marked email as spamtruesuppression (complaint)
Legal basis for processing contact's dataany valuekept in each grant's evidence as legalBasis

Every grant and withdrawal is recorded in the consent ledger with basis imported and evidence naming where it came from, for example imported from Marketing Cloud (MCE): publication list "Product News" in publication-lists/Product News.csv, status Active (plus system, file, the status, the list and the date joined). An imported grant lets the person be emailed, but never re-opens email after an opt-out (only an express grant does). Only bring people who opted in: an import is not consent of its own.

Engagement dates

Engagement dates in the export are kept on each contact so warm-up can send to your most engaged people first:

PropertyFrom
imported_last_open_at (ISO timestamp)MCE: tracking/Opens.csv EventDate, or a data extension column Last Open Date / Last Opened; HubSpot: Last marketing email open date / Last email open date
imported_last_click_at (ISO timestamp)MCE: tracking/Clicks.csv EventDate, or Last Click Date / Last Clicked; HubSpot: Last marketing email click date / Last email click date

The newest date wins across files. A Last engaged date / Last activity date column fills imported_last_open_at only when nothing gives an open date.

Emails: AMPscript and HubL

What converts safely becomes Liquid; what does not is removed (an inbox would show it raw) and listed under needs a person in the report.

Marketing CloudPlainsend
%%FirstName%%, %%First Name%%, any %%Attribute%%{{ contact.first_name | default: 'there' }}, {{ contact.attribute | default: '' }}
%%emailaddr%%{{ contact.email }}
%%unsub_center_url%%, %%profile_center_url%%{{ unsubscribe_url }}, {{ preferences_url }}
%%Member_Busname%%, %%Member_Addr%% %%Member_City%% ...{{ brand.name }}, {{ brand.postal_address }}
%%xtyear%%, %%xtmonth%%, %%xtday%%{{ 'now' | date: '%Y' }} ...
SET @x = AttributeValue("A") / [A] / "literal", then %%=v(@x)=%%the attribute (or the literal)
%%=ProperCase(@x)=%%, Uppercase, Lowercase| capitalize, | upcase, | downcase
%%=RedirectTo("https://...")=%% (or a variable set to a literal)the URL
IF ... THEN / ELSEIF / ELSE / ENDIF on attributes and literals (==, !=, <>, <, >, <=, >=, EMPTY(), NOT EMPTY(), AND, OR){% if %} / {% elsif %} / {% else %} / {% endif %}

Flagged: Lookup, LookupRows and other functions (the variable prints nothing), system strings such as %%jobid%% (removed), the view-in-browser link (removed with its link: there is no web version yet), an IF whose condition cannot be read ({% if false %} keeps only its ELSE content; the other branches stay in the HTML for you to rewrite), FOR loops (kept inside {% if false %}), statements that write data (dropped). AMPscript compares text ignoring case and Liquid does not: each converted comparison says so.

HubSpotPlainsend
{{ contact.firstname }}, {{ personalization_token("contact.company", "your company") }}{{ contact.first_name | default: 'there' }}, {{ contact.company | default: 'your company' }}
{{ unsubscribe_link }}, {{ unsubscribe_link_all }}, {{ unsubscribe_section }}{{ preferences_url }}, {{ unsubscribe_url }}, both links
{{ site_settings.company_name }}, {{ site_settings.company_street_address_1 }}{{ brand.name }}, {{ brand.postal_address }}
{% if contact.x == "y" %} / {% elif %} / {% else %} / {% endif %}the same in Liquid

Flagged: {% module %}, widgets and drag-and-drop areas (removed; the HTML around them stays), the view-as-web-page link, any other token.

Journeys (Marketing Cloud)

A Journey Builder export becomes a draft journey. It goes live only through the go-live approval.

Journey BuilderPlainsend step
Email activitysend of the template with the email's name (missing: the step is left out and the report says so)
Wait by duration (minutes, hours, days, weeks; months as 30 days)wait
Decision split with one attribute condition per pathbranch (one per path; the remainder path is the last else)
Engagement split (opened, clicked)branch on opened / clicked of that send step
Random splitsplit (the first two paths)
SMS, push, update contact, wait until a date, anything elseleft out, people go straight on; listed in the report
Entry: a data extension in the exporta static list de-<name> of its people; the journey enters on it (existing: skip: people already on it do not enrol)
Entry: an API evententry on that event (send it with track)

The journey sends under options.journeySubscriptionType, else the first subscription type the migration brings.

Plan, then apply

SurfacePlanApply
APIPOST /v1/migrate:plan {system, files[{path, content, encoding?}], options?}POST /v1/migrate:apply (same body, activate?)
CLIplainsend migrate mce|hubspot <folder>... --apply (--out bundle.json writes the bundle)
MCPmigrate_planmigrate_apply
ConsoleSettings → Migrate: upload the folder, read the report and the planApply

files are text (CSV, HTML, JSON) or base64 with encoding: "base64" (XLSX). options: brand (the brand people, types and emails go to; default the workspace's only brand; it must exist), allSubscribersType, journeySubscriptionType, defaultSubject, files: {"<path>": {"as": "publication_list", "name": "..."}} for a file whose folder and name do not say what it is, and includeBundle (the plan returns the bundle too).

The plan writes nothing and returns the migration report and the bundle's plan:

Apply writes the bundle in dependency order and stops at the first error, saying what was applied. Bringing people with consent needs an owner (an agent may plan; its apply stops at the contacts). Journeys stay drafts behind one go-live approval. Applying the same export again changes nothing.

Limits: 25,000 contacts per migration (split a larger export into folders, or bring people through a Spreadsheet source); 500 files per request.

Exporting out

The workspace as a bundle

GET /v1/workspace:export (plainsend workspace export [dir], MCP workspace_export) returns every resource's current document and no secret: settings, domains, brands, forms, sources (credentials named, never included), goals, blocks, brand kits, templates, lists, segments, journeys and broadcast drafts (keyed by their name).

QueryAdds
members=trueeach list's members, as addresses
suppressions=truethe suppression list (address, reason, brand, date, note)
journeys=activeeach journey's live version instead of its newest (a journey never switched on: its newest)
journeys=allthe newest version, plus every version under history.journeys (kept for the record; plan and apply ignore it)

Applying an export into another workspace reproduces it; planning it there again shows everything unchanged.

POST /v1/contacts:export (plainsend contacts export, MCP contacts_export, console Settings → Migrate → Export) starts an export of every contact with its whole consent ledger, written in the background. Owners only; the request and every download are in the activity log. GET /v1/exports/:id (export_status) shows progress and, when done, a signed download link valid for 24 hours (each read gives a fresh one). The file is NDJSON, one contact a line:

{"id":"…","personId":"…","email":"ana@example.com","phone":null,"firstName":"Ana","lastName":"Lopez","country":null,"region":null,"locale":null,
 "properties":{"tier":"Gold"},"createdAt":"…","updatedAt":"…",
 "consent":[{"brand":"main","subscriptionType":"news","channel":"email","status":"granted","basis":"express","source":"form-newsletter","evidence":{…},"occurredAt":"…"}]}

Start your workspace

Two quick questions, then your next step.

We store these details to help you get set up, and nothing else. Privacy