# 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) and `counts` (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. `contacts` says 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:

```json
{"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":"…"}]}
```
