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")
| File | Where to get it | What it becomes |
|---|---|---|
emails/<name>.html | Content Builder: open the email, copy its HTML (Code view) into a file named after the email | an HTML template; AMPscript converted (below); the <title> is the subject |
Data Extensions/<name>.csv | Contact Builder → Data Extensions → Export (or a data extract) | contacts: known columns to fields, SubscriberKey to mce_subscriber_key, the rest to properties |
all-subscribers.csv | Email Studio → Subscribers → All Subscribers → Export | Unsubscribed: a withdrawal of all the brand's email (*); Held / Bounced: a suppression (hard bounce); Active: see below |
publication-lists/<list name>.csv | Email Studio → Subscribers → Publication Lists → each list → Export | one subscription type per list (added to the brand); Active: granted; Unsubscribed: withdrawn; Held / Bounced: suppression |
tracking/Opens.csv, tracking/Clicks.csv | Tracking extract (Automation Studio data extract, Tracking Extract) | each person's newest open and click (see engagement dates) |
journeys/<name>.json | Journey 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")
| File | Where to get it | What it becomes |
|---|---|---|
emails/<name>.html | Marketing → Email → the email → Actions → Export (HTML) | an HTML template; HubL converted (below) |
contacts.csv (any CSV not in lists/) | Contacts → Export, with All properties | contacts: known columns to fields (Company Name → company, Lifecycle Stage → lifecycle_stage, Record ID → hubspot_record_id), the rest to properties |
lists/<list name>.csv | Contacts → Lists → the list → Export | a 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):
| Column | Value | Becomes |
|---|---|---|
Unsubscribed from all email | true | withdrawn, all the brand's email (*) |
Opted out of email: <type> | true | withdrawn, that type |
Subscription status: <type> (or Subscription: <type>) | Subscribed / Opted in | granted, that type |
| Unsubscribed / Opted out | withdrawn, that type | |
Email hard bounce reason | any value | suppression (hard bounce) |
Marked email as spam | true | suppression (complaint) |
Legal basis for processing contact's data | any value | kept in each grant's evidence as legalBasis |
Consent and its evidence
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:
| Property | From |
|---|---|
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 Cloud | Plainsend |
|---|---|
%%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.
| HubSpot | Plainsend |
|---|---|
{{ 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 Builder | Plainsend step |
|---|---|
| Email activity | send 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 path | branch (one per path; the remainder path is the last else) |
| Engagement split (opened, clicked) | branch on opened / clicked of that send step |
| Random split | split (the first two paths) |
| SMS, push, update contact, wait until a date, anything else | left out, people go straight on; listed in the report |
| Entry: a data extension in the export | a static list de-<name> of its people; the journey enters on it (existing: skip: people already on it do not enrol) |
| Entry: an API event | entry on that event (send it with track) |
The journey sends under options.journeySubscriptionType, else the first subscription type the migration brings.
Plan, then apply
| Surface | Plan | Apply |
|---|---|---|
| API | POST /v1/migrate:plan {system, files[{path, content, encoding?}], options?} | POST /v1/migrate:apply (same body, activate?) |
| CLI | plainsend migrate mce|hubspot <folder> | ... --apply (--out bundle.json writes the bundle) |
| MCP | migrate_plan | migrate_apply |
| Console | Settings → Migrate: upload the folder, read the report and the plan | Apply |
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:
- report:
files(each with how it was read),converted,needsPerson,skipped(each item: kind, name, file, detail) andcounts(templates, contacts, subscription types, consent granted and withdrawn, suppressions, lists, journeys). - plan: the workspace plan, per resource (the brand's new subscription types,
contacts,suppressions, each template, list and journey): create, update or unchanged, with its issues.contactssays how many people would be created, updated or left alone and how much consent would be granted or withdrawn.
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).
| Query | Adds |
|---|---|
members=true | each list's members, as addresses |
suppressions=true | the suppression list (address, reason, brand, date, note) |
journeys=active | each journey's live version instead of its newest (a journey never switched on: its newest) |
journeys=all | the 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.
Contacts and consent
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":"…"}]}