Plain to HubSpot Service Hub Migration: A Technical Guide
Technical guide for migrating from Plain to HubSpot Service Hub. Covers GraphQL extraction, data model mapping, rate limits, and zero-downtime cutover.
Planning a migration?
Get a free 30-min call with our engineers. We'll review your setup and map out a custom migration plan — no obligation.
Schedule a free call- 1,500+ migrations completed
- Zero downtime guaranteed
- Transparent, fixed pricing
- Project success responsibility
- Post-migration support included
Plain to HubSpot Service Hub Migration: A Technical Guide
TL;DR: A Plain to HubSpot Service Hub migration is moderate-to-high complexity — the data model maps cleanly, but the extraction path is entirely API-driven. Plain has no CSV export for full thread history, so you must extract everything through its GraphQL API with a workspace rate limit of 450 requests per window (window duration: 60 seconds). Each Plain Thread becomes a HubSpot Ticket with associated Engagement records (emails, notes), linked to Contacts and Companies via the Associations v4 API. Plain's Custom Timeline Entries, Customer Events, and Tenant model have no native HubSpot equivalent and require either Custom Objects (Enterprise-only) or serialization into standard notes. Realistic timeline: 2–4 weeks for 10K–50K threads, 4–6 weeks for larger datasets with attachments and complex schemas.
Moving from Plain to HubSpot Service Hub is a fundamental shift in how your organization treats customer support. Plain is designed for technical teams that want a programmable, API-first support tool tightly integrated with developer workflows. HubSpot Service Hub integrates support directly into the revenue engine, tying tickets to CRM pipelines, marketing campaigns, and sales activities.
This is not a lift-and-shift. You are translating between two different data philosophies — Plain's GraphQL-based, event-driven timeline model and HubSpot's REST-based, relational CRM model. This guide covers the architectural differences, API constraints, extraction mechanics, rate limit math, and step-by-step mapping required to execute a clean migration.
Why Teams Move from Plain to HubSpot Service Hub
Plain is purpose-built for technical B2B teams that want deep API programmability and developer-tool integrations. Plain is a customer support tool aimed at B2B SaaS companies that want a developer-friendly, API-first alternative to legacy helpdesk products. Built by ex-Deliveroo and Intercom engineers, the platform emphasizes a clean keyboard-driven UI, deep customer context from product data, and integrations with Linear, GitHub, and other developer tools.
Teams migrate to HubSpot when the trade-off flips: the cost of maintaining custom Plain integrations exceeds the value of its programmability. Specific triggers:
- CRM unification — a single platform where sales, marketing, and support share the same contact and company records without custom sync logic
- Multi-pipeline ticket management — HubSpot supports multiple ticket pipelines with granular stage automation; Plain's model is limited to Todo/Done/Snoozed
- Built-in cross-object reporting — tickets tied to deals, contacts, and companies in one reporting layer
- Knowledge base — HubSpot includes SEO-optimized templates and analytics; Plain ships no knowledge base
- No-code workflow automation — HubSpot Workflows cover SLA escalations, conditional branching, and multi-step sequences without engineering involvement
The trade-off is real: Plain is the most API-complete support platform available. If you are evaluating platforms by programmability — GraphQL vs REST, rate limits, webhook depth, SDK quality, AI agent connectivity — it is in a class of its own. Verify that HubSpot's workflow builder and REST API cover your automation requirements before committing.
Plain vs. HubSpot: Data Model Mapping
Before writing extraction scripts, understand how Plain's objects translate to HubSpot's. Plain uses a flat, event-driven timeline architecture. HubSpot uses a relational, object-oriented CRM model.
Threads are the core of Plain's data model and are equivalent to tickets or conversations in other support platforms. Each thread belongs to one customer and has a status which is one of Todo, Done, or Snoozed.
| Plain Object | HubSpot Object | Notes |
|---|---|---|
| Customer | Contact | Email is the primary match key. |
| Company | Company | Match on domain. HubSpot auto-deduplicates on company domain. |
| Tenant | Custom property on Company/Contact | HubSpot has no native Tenant equivalent. Store externalId in a custom property. See tenant decision tree below. |
| Thread | Ticket | One thread = one ticket. Map status to pipeline stage. |
| Timeline Entry (Email/Chat/Slack) | Engagement (Email) | Each message becomes an engagement with original timestamp and sender mapping. Channel attribution (Slack vs. email) is lost unless stored as a custom engagement property. |
| Timeline Entry (Note) | Engagement (Note) | Internal notes on threads. |
| Custom Timeline Entry | Engagement (Note) | Serialize componentJson into human-readable text; HubSpot has no custom UI components in ticket timelines. |
| Labels | Tags or Custom Property | Labels are freeform in Plain; map to HubSpot tags or a multi-select property. |
| Thread Fields | Custom Ticket Properties | Must be pre-created in HubSpot before import. Match data types carefully. |
| Thread Status (Todo/Done/Snoozed) | Pipeline Stage | Map to Open, In Progress, Closed (or custom stages). Snoozed → "Waiting on Customer" custom stage. |
| Assignments (user/team) | Owner | Map Plain users to HubSpot owners by email. |
| Customer Events | No native equivalent | Requires Custom Timeline Events (Enterprise) or archived as notes. |
| SLA/Tier config | HubSpot SLAs (Service Hub Pro+) | Must be rebuilt. Config does not transfer. |
What Does Not Migrate
Some Plain features are runtime constructs or platform-specific configurations with no portable data:
- Automations and Workflows — Plain's rule-based automations and webhook-driven workflows must be rebuilt as HubSpot Workflows.
- Customer Cards — Live API-fetched data panels rendered at runtime. No data to migrate; rebuild equivalent functionality using HubSpot's custom sidebar cards.
- AI Agent config (Ari) — Plain's AI agent settings, training data, and routing logic do not transfer. Configure HubSpot's AI tools independently.
- Linear/GitHub issue links — These links can be preserved as custom properties or notes on HubSpot tickets, but bidirectional sync stops.
- Saved Views and Queue config — Workspace settings, not data. Rebuild in HubSpot Views.
Tenant-to-HubSpot Mapping: Decision Tree
The tenant data model is the hardest architectural decision in a Plain-to-HubSpot migration. Plain's Tenant object supports multi-tenant customer organization: customers can belong to multiple tenants, each mapped to your own database via externalId. HubSpot has no native Tenant object. Choose your mapping strategy before extraction begins, because it determines your Company schema and association model.
Decision tree:
Does each tenant map 1:1 to a customer-facing company?
├── YES → Store tenant as a standard HubSpot Company.
│ Map Plain tenant.externalId to a custom Company
│ property (e.g., plain_tenant_id).
│ Associate contacts and tickets to this Company.
│
└── NO: Can customers belong to multiple tenants?
├── YES → Tenants are NOT Companies. Two options:
│ (a) Store tenant IDs as a multi-value custom
│ property on Contact records.
│ (b) Enterprise only: Create a Tenant custom
│ object with Contact associations.
│ Warning: option (a) loses tenant-scoped
│ filtering post-migration.
│
└── NO: Are tenants billing/account units distinct
from organizational companies?
├── YES → Create one HubSpot Company per tenant,
│ plus a second Company for the parent org.
│ Use parent-child Company associations
│ (HubSpot supports this natively).
│
└── NO → Treat tenant as a custom property on
the existing Company record.
Sufficient when tenants are internal
segmentation, not customer-facing units.
If your support workflow depends heavily on tenant-scoped views or SLAs, this is a significant workflow change post-migration regardless of which option you choose. HubSpot's filtering and reporting are object-centric; tenant-as-property is a demotion in queryability.
Extracting Data from Plain via GraphQL
The API is the only extraction path
Plain does not offer a bulk CSV export for threads or full conversation history. All data extraction runs through Plain's GraphQL API.
Key API details:
- Endpoint:
https://core-api.uk.plain.com/graphql/v1 - Auth: Bearer token (API key from Settings → API Keys)
- Schema introspection:
https://core-api.uk.plain.com/graphql/v1/schema.graphql - TypeScript SDK:
@team-plain/typescript-sdkon npm - Rate limit: The limit for your workspace is 450 requests per window, with headers
x-ratelimit-limit,x-ratelimit-remaining, andx-ratelimit-resetreturned on every response. The window duration is 60 seconds. Thex-ratelimit-resetheader returns the Unix timestamp at which the window resets — sleep until that timestamp before issuing further requests.
Rate limit math for extraction planning
With a 450-request/60-second window limit, the extraction duration for large datasets is non-trivial. Here is the calculation for a 50K-thread workspace:
- Thread list queries (50 threads per page): 50,000 ÷ 50 = 1,000 requests
- Timeline queries (one per thread): 50,000 requests
- Customer/Company/Label queries: ~500 requests
- Total: ~51,500 requests
- Windows required: 51,500 ÷ 450 = ~115 windows
- Wall-clock time: 115 windows × 60 seconds = ~1.9 hours of pure API time
Add network overhead, retry latency on 429s, and timeline pagination for threads with long histories, and real-world extraction for 50K threads takes 3–5 hours. For 200K threads, budget 12–20 hours of extraction time. This directly affects your cutover window planning.
Extraction sequence
Extract in dependency order to avoid referencing IDs that do not yet exist in your staging database:
- Users — All workspace members (agents). Required to map Plain assignment IDs to HubSpot Owner IDs.
- Customers — All customers with email, name, company association, and external IDs.
- Companies — Companies with domain and metadata.
- Labels — Full label set for mapping to HubSpot tags or properties.
- Threads — All threads with status, labels, assignments, thread fields, timestamps, and customer association.
- Thread Timelines — For each thread, the full timeline: messages, notes, status changes, events.
Rate limit management: Parse x-ratelimit-remaining on every response. When remaining hits 0, read x-ratelimit-reset (Unix timestamp), compute reset_time - now(), and sleep for that duration plus a 200ms buffer. Do not use a fixed sleep — windows are time-based, not request-count-based, and a fixed sleep wastes extraction time. Implement exponential backoff (starting at 1s, capping at 32s) for 429 responses that occur despite header-based throttling.
Extracting threads with pagination
Plain's API uses Relay-style cursor pagination. Request a batch, check pageInfo.hasNextPage, and pass pageInfo.endCursor into the next request's after variable.
query GetThreads($after: String) {
threads(first: 50, after: $after) {
edges {
node {
id
title
status
createdAt
updatedAt
customer {
id
email
fullName
}
tenant {
id
externalId
name
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}For the TypeScript SDK approach:
import { PlainClient } from '@team-plain/typescript-sdk';
const client = new PlainClient({ apiKey: process.env.PLAIN_API_KEY! });
async function extractAllThreads() {
let cursor: string | null = null;
const allThreads = [];
do {
const result = await client.getThreads({
first: 50,
after: cursor ?? undefined,
});
if (result.error) throw new Error(result.error.message);
allThreads.push(...result.data.edges.map(e => e.node));
cursor = result.data.pageInfo.hasNextPage
? result.data.pageInfo.endCursor
: null;
} while (cursor);
return allThreads;
}Extracting timeline entries per thread
Threads are containers. The actual conversation history lives in Timeline Entries. For every thread extracted, make a subsequent query to fetch its timeline.
Each thread has a timeline. The timeline contains all relevant communication, events and other updates such as assignment changes. Over 20 event types are available: thread created, email received and sent, Slack message received and sent, status transitions, assignment changes, label changes, priority changes, SLA status transitions, customer events, and thread field changes.
Use GraphQL fragments to handle different entry types:
query GetTimelineEntries($threadId: ID!, $after: String) {
timelineEntries(threadId: $threadId, first: 50, after: $after) {
edges {
node {
id
timestamp
... on EmailEntry {
subject
textContent
htmlContent
from {
email
}
}
... on NoteEntry {
markdownContent
}
... on CustomEntry {
componentJson
}
... on SlackMessageEntry {
text
slackChannelId
slackTeamId
}
}
}
pageInfo {
hasNextPage
endCursor
}
}
}Filter for message-type events (emails, Slack messages, chat messages, notes) and decide whether to preserve system events (status changes, assignment changes) as HubSpot notes for audit purposes.
Delta sync query for cutover
For the final delta sync before cutover, Plain's threads query supports filtering by updatedAfter. Use this to capture only threads created or modified since your initial extraction:
query GetUpdatedThreads($updatedAfter: DateTime!, $after: String) {
threads(
first: 50,
after: $after,
filters: { updatedAfter: $updatedAfter }
) {
edges {
node {
id
updatedAt
status
}
}
pageInfo {
hasNextPage
endCursor
}
}
}Store the timestamp of your initial extraction run in your staging database. Pass it as $updatedAfter in the delta query. This limits the final sync to only changed records rather than re-extracting everything.
Transforming the Payload
Data extracted from Plain cannot be passed directly into HubSpot. Required transformations:
Markdown to HTML conversion
Plain relies heavily on Markdown for notes and text entries. HubSpot's Engagements API expects valid HTML. Raw Markdown pushed into a HubSpot engagement renders as plain text with visible asterisks and hashes — unusable for support agents.
Pass all Markdown content through a parser (markdown-it in Node.js or Mistune in Python) before building the HubSpot payload.
Flattening Custom Timeline Entries
Plain's Custom Timeline Entries are componentJson payloads that render as custom React components in the support timeline — billing failure widgets, usage metrics, feature flag states. HubSpot has no equivalent in the ticket timeline.
To preserve this historical context, serialize the componentJson into a human-readable text string and insert it into HubSpot as a standard Note engagement, prefixed with [CUSTOM EVENT — Plain] so agents understand its origin.
Timestamp conversion
Preserving the original interaction time is non-negotiable. If you fail to override the timestamp, every migrated ticket appears as if it was created on migration day.
Plain returns ISO 8601 timestamps. HubSpot only accepts milliseconds as the Unix timestamp for engagement timestamps. Convert 2023-10-31T14:23:52Z → 1698762232000 before import. Use hs_createdate for ticket creation dates and hs_timestamp for engagement timestamps. Failure to convert produces correct-looking data with incorrect timeline ordering.
Other required mappings
- Plain thread status → HubSpot pipeline stage ID (retrieve your stage IDs from
GET /crm/v3/pipelines/tickets) - Plain user IDs → HubSpot owner IDs (match by email via
GET /crm/v3/owners) - Plain label names → HubSpot tag values or multi-select options (pre-create options before import)
- Thread field values → Custom property values (validate against HubSpot property types; mismatched types fail silently on some endpoints)
Loading Data into HubSpot Service Hub
Load in strict dependency order: Contacts and Companies first, Tickets second, Engagements third. Creating a Ticket before its Contact exists means the association either fails or must be created in a second pass.
Pre-create custom properties and pipeline stages
Before importing any records:
- Create custom ticket properties for each Plain Thread Field. Match data types: Plain text fields → HubSpot single-line text; dropdown Thread Fields → HubSpot select properties with pre-created options.
- Configure your ticket pipeline with stages mapping to Plain's statuses. At minimum:
Open(Todo),Waiting on Customer(Snoozed),Closed(Done). - Create
plain_thread_idon Tickets andplain_customer_idon Contacts as single-line text properties. These are your deduplication keys for reruns and delta syncs.
HubSpot sandbox vs. production: a critical trap
Test migrations in HubSpot's sandbox account — but understand that sandbox object IDs are entirely separate from production IDs. A Contact created in sandbox with ID 12345 does not exist in production. Your migration scripts cannot be "promoted" from sandbox to production; the full import must be re-run against the production portal. What you validate in sandbox is the correctness of your transformation logic and data shape, not the actual records.
This is the most common cause of wasted migration effort on large projects. Build your scripts to accept a --target-portal-id flag and derive HubSpot API credentials from environment variables, so re-running against production requires a config change, not a code change.
Import Contacts and Companies
Use HubSpot's batch API to create or update Contacts and Companies:
POST /crm/v3/objects/contacts/batch/create
Batch up to 100 records per request. Match Contacts on email using the idProperty=email upsert endpoint to avoid duplicates. Match Companies on domain. Store the HubSpot IDs returned — you need them for ticket associations.
Create Tickets with associations
To create new tickets, make a POST request to /crm/v3/objects/tickets. Include associations in the same create call to link the ticket to its Contact and Company:
{
"inputs": [
{
"properties": {
"hs_pipeline": "0",
"hs_pipeline_stage": "1",
"subject": "API Authentication Failure",
"content": "User reported a 401 error on the /v2/users endpoint.",
"plain_thread_id": "th_abc123",
"hs_createdate": "1698765432000"
},
"associations": [
{
"to": {
"id": "10456"
},
"types": [
{
"associationCategory": "HUBSPOT_DEFINED",
"associationTypeId": 16
}
]
}
]
}
]
}Verify association type IDs before hardcoding them. Association type IDs can vary by portal configuration and HubSpot periodically updates them. The value 16 represents the standard Ticket-to-Contact association in most portal configurations, but you must confirm the correct IDs for your portal by calling:
GET /crm/v4/associations/tickets/contacts/labels
This returns the canonical association type IDs for your specific portal. Do the same for Ticket-to-Company (/crm/v4/associations/tickets/companies/labels) before writing your import script.
Use the batch endpoint (/crm/v3/objects/tickets/batch/create) for throughput — up to 100 tickets per call.
Create Engagements for thread messages
Each message from a Plain thread timeline becomes an Engagement in HubSpot. To create an email engagement, make a POST request to /crm/v3/objects/emails. In the request body, add email details in a properties object.
For emails:
{
"properties": {
"hs_timestamp": "1698765500000",
"hs_email_direction": "INCOMING_EMAIL",
"hs_email_subject": "Re: API Authentication Failure",
"hs_email_html": "<p>Thanks, the new token works.</p>",
"hs_email_from_email": "customer@example.com"
},
"associations": [
{
"to": {
"id": "98765"
},
"types": [
{
"associationCategory": "HUBSPOT_DEFINED",
"associationTypeId": 228
}
]
}
]
}Confirm associationTypeId 228 (Email-to-Ticket) via GET /crm/v4/associations/emails/tickets/labels on your portal.
For internal notes from the Plain timeline:
POST /crm/v3/objects/notes
API call volume planning: For a 50K-thread migration with an average of 8 messages per thread:
- Engagement creates: 50,000 × 8 = 400,000 calls
- Ticket-to-engagement associations: 400,000 calls
- Contact-to-engagement associations: 400,000 calls
- Total engagement-related calls: ~1.2 million
At HubSpot Professional burst limit of 190 requests/10 seconds = ~68,400 calls/hour. 1.2 million calls ÷ 68,400 = ~17.5 hours for engagements alone. Plan for multi-day import runs. The daily limit of 650,000 requests on Professional means you will hit the daily cap before the burst cap — spread engagement imports across at least 2 days.
Map Labels and Thread Fields
Labels are a lightweight way of categorizing threads by topic (e.g., bugs, feature requests, demo request, etc.). A thread can have one or more labels, and each label has a name and an icon.
Two options in HubSpot:
- HubSpot Tags (available on the newer Help Desk workspace) — closest match to Plain Labels. Multi-value, filterable.
- Custom multi-select property — create a
plain_labelsproperty with predefined options matching your Plain label names. Pre-create all option values before import; options not pre-created are silently dropped.
Thread Fields map to custom ticket properties. Match data types before import: a Plain dropdown field pushed into a HubSpot text property loses its structured value; a Plain text field pushed into a HubSpot select property fails if the value is not a pre-created option.
Rate Limits and Throughput Planning
| Platform | Limit Type | Value |
|---|---|---|
| Plain | Workspace request window | 450 requests per 60-second window |
| HubSpot (Starter) | Burst / Daily | 100 per 10s / 250,000 per day |
| HubSpot (Professional) | Burst / Daily | 190 per 10s / 650,000 per day |
| HubSpot (Enterprise) | Burst / Daily | 190 per 10s / 1,000,000 per day |
| HubSpot Search API | Per second | 4 requests/second (all tiers) |
| HubSpot Files API | Max file size | 2GB per file; portal storage cap varies by plan |
HubSpot uses a token bucket model for rate limiting. Imagine a bucket that holds 100 tokens (for a Starter account). Every request you make removes one token. The bucket refills at a constant rate — roughly 10 tokens per second. When the bucket is empty, you get a 429.
For the import side, always implement:
- Exponential backoff on 429 responses (start at 1s, double up to 32s)
- Rate-limit header parsing — monitor
X-HubSpot-RateLimit-Daily-RemainingandRetry-Afterheaders - Checkpoint/resume logic — store the last successfully imported Plain thread ID in your staging database so you can restart without duplicating data
- Daily limit monitoring — at Professional, 650,000 requests/day is the binding constraint for large migrations, not the burst limit
Edge Cases and Failure Modes
| Failure Mode | Trigger Condition | Detection | Remediation |
|---|---|---|---|
| Broken inline images | Signed image URLs in Plain Markdown have expired, or base64 images not extracted | Images render as broken links in HubSpot engagement | Download all images during extraction phase; re-upload to HubSpot Files API (/files/v3/files) before HTML insert |
| Slack channel attribution lost | Plain Slack messages have no HubSpot channel equivalent | No native detection — compare record counts by entry type | Store original channel (slack, discord, email) in a custom engagement property during transform |
| Customer Events dropped | Plain Customer Events have no HubSpot equivalent below Enterprise | Events missing from contact timeline post-migration | Enterprise: use Custom Timeline Events API. Professional: serialize as Note engagements with [CUSTOMER EVENT] prefix |
| Tenant context lost | Plain tenant-scoped views not reproducible in HubSpot | Support agents cannot filter by tenant post-migration | Implement tenant decision tree above before extraction; ensure tenant IDs are stored on Company or Contact records |
| Association type ID mismatch | Hardcoded associationTypeId values differ from portal config |
Associations silently fail; tickets not linked to contacts | Fetch correct IDs via GET /crm/v4/associations/{from}/{to}/labels before import |
| Markdown renders as plain text | Raw Markdown pushed to HubSpot without HTML conversion | Asterisks and hashes visible in HubSpot ticket body | Run all markdownContent fields through markdown-it or Mistune before constructing payload |
| Attachment storage overflow | Large attachment volumes exceed portal file storage cap | Files API returns storage limit error | Audit attachment volume before migration; consider external file storage with URL references for large archives |
| Delta sync misses updates | Initial extraction timestamp not recorded; delta query range incorrect | Tickets in Plain updated between initial sync and cutover are missing in HubSpot | Record extraction start timestamp in staging DB; use updatedAfter filter in delta query |
| Sandbox IDs used in production | Migration scripts built against sandbox portal IDs | Associations fail on production import; records not linked | Run GET /crm/v3/owners and pipeline/stage queries against production portal to populate ID maps before production import |
| Custom property options missing | Multi-select property options not pre-created before import | Option values silently dropped; labels/thread fields missing | Extract all unique label names and thread field option values; create HubSpot property options via API before first import |
Customer Events detail
Events belong to a customer or thread and allow you to log important actions that happen outside of Plain within Plain. Events provide you with additional context of the customer's actions when you are helping them. For example, if you log an event when a customer deletes an API key in your systems, then if they reach out reporting 401 errors — you immediately know why.
HubSpot Custom Timeline Events are Enterprise-only. On Professional, your options are: archive events as notes on the contact record (loses queryability), store in a custom object (also Enterprise-only), or export to a data warehouse and accept that this context will not live in HubSpot.
Migration Script Architecture
A well-structured migration pipeline follows strict ETL stages:
1. Extract from Plain (GraphQL) → Write to local SQLite/PostgreSQL staging
2. Transform (data mapping) → Map IDs, convert timestamps, parse Markdown → HTML,
validate property types, resolve associations
3. Load to HubSpot (REST) → Batch create with associations in dependency order
4. Verify (diff check) → Compare record counts, spot-check 50 threads end-to-end
5. Delta sync → Re-extract threads updated after initial extraction timestamp
6. Cutover → Route incoming mail to HubSpot; archive Plain workspace
Use a staging database. Do not pipe directly from Plain to HubSpot. A local SQLite or PostgreSQL staging database provides:
- Rerun transform without re-extracting from Plain (saves hours of API quota)
- Debug mapping issues without consuming HubSpot API quota
- Validation queries before loading (count mismatches, null checks)
- Resume after failures at the record level, not the run level
- ID mapping tables:
plain_thread_id → hubspot_ticket_id,plain_customer_id → hubspot_contact_id
Structure your staging schema around IDs:
CREATE TABLE threads (
plain_thread_id TEXT PRIMARY KEY,
hubspot_ticket_id TEXT,
extraction_status TEXT, -- 'extracted', 'transformed', 'loaded', 'verified'
load_error TEXT,
created_at_plain TEXT,
updated_at_plain TEXT
);
CREATE TABLE timeline_entries (
plain_entry_id TEXT PRIMARY KEY,
plain_thread_id TEXT REFERENCES threads(plain_thread_id),
hubspot_engagement_id TEXT,
entry_type TEXT, -- 'email', 'note', 'custom', 'slack'
load_status TEXT
);This schema lets you query WHERE load_status IS NULL to resume failed loads and WHERE hubspot_ticket_id IS NULL to find threads that extracted but did not load.
Test Migration and Cutover
What to validate
Run a test migration with a representative sample (500–1,000 threads, stratified by status and entry type) before the full run. Validate:
| Check | Method | Pass Criterion |
|---|---|---|
| Record counts | COUNT in staging vs. HubSpot API | Thread count = ticket count ± 0 |
| Message ordering | Open 10 tickets in HubSpot; compare timeline | Engagements in correct chronological order |
| Association integrity | GET /crm/v3/objects/tickets/{id}/associations/contacts for 50 tickets |
All tickets linked to Contact and Company |
| Markdown rendering | Visual inspection in HubSpot | No raw Markdown syntax visible |
| Label mapping | Spot-check 20 tickets | Labels present as expected property values |
| Thread field values | Spot-check 20 tickets | Custom properties match Plain source values |
| Attachment accessibility | Open 10 attachments in HubSpot | Files render without broken links |
| Agent assignments | Compare owner field on 20 tickets | Owner email matches Plain assignee |
| Tenant mapping | Check Company plain_tenant_id property on 20 contacts |
Tenant external ID present and correct |
| Delta sync accuracy | Run delta query after test load; count results | Only threads updated after extraction timestamp returned |
Cutover sequence
- Audit and Map — Map Plain Tenants to HubSpot Companies using the decision tree above. Map Plain Users to HubSpot Owners by email. Document all custom properties needed and pre-create them.
- Schema Setup — Create custom properties (
plain_thread_id,plain_customer_id, Thread Field properties). Configure pipeline stages. Verify association type IDs against production portal. - Test Migration — Extract 500–1,000 representative threads from Plain. Transform and load into HubSpot sandbox. Run full validation checklist.
- UAT — Have support agents verify rendering and timeline ordering in sandbox. Fix mapping bugs before production run.
- Production Initial Sync — Re-run extraction and load against production HubSpot portal using production credentials and ID maps. Record extraction start timestamp.
- Delta Sync and Cutover — Pause incoming requests to Plain (set an auto-reply). Run delta script using
updatedAfterfilter for threads changed since initial sync. Route incoming mail to HubSpot. Archive Plain workspace.
For zero-downtime strategies during cutover, see Zero-Downtime Help Desk Data Migration.
Timeline and Effort Estimates
| Scenario | Threads | Estimated Duration | Primary Constraint |
|---|---|---|---|
| Small team, simple schema | <10K | 1–2 weeks | Script development |
| Mid-size team, thread fields + labels | 10K–50K | 2–4 weeks | Extraction time (~4–8 hrs), HubSpot import (~2 days) |
| Large dataset, attachments + events | 50K–200K | 4–6 weeks | HubSpot daily rate limit (multi-day import runs) |
| Enterprise with tenants + custom objects | 200K+ | 6–8 weeks | Tenant schema design + multi-day parallel imports |
These estimates include planning, HubSpot setup, script development, test migration, validation, and cutover. The bulk of engineering time goes into extraction and transform scripts — the HubSpot import side is well-documented but throughput-constrained by rate limits on large datasets.
When to Self-Serve vs. Use a Migration Service
Self-serve works well when:
- Thread count is under 10K
- No attachments or minimal attachments
- Simple thread fields (text only), no tenant complexity
- Your team has a developer comfortable with GraphQL pagination and HubSpot's REST API
- Your support workflow does not depend on Customer Events or Custom Timeline Entry context
A managed migration makes sense when:
- Thread count exceeds 50K (multi-day import runs benefit from monitoring infrastructure)
- Complex thread fields, tenant structures, or Customer Events need architectural decisions
- Attachments are significant in volume (requires Files API handling and storage audit)
- Downtime tolerance is zero and you need a parallel-run strategy
- Your engineering team's time cost exceeds the cost of a migration service
Making the Switch
A Plain to HubSpot Service Hub migration is tractable for teams with API experience, but it demands more engineering effort than typical helpdesk migrations because Plain's extraction path is entirely GraphQL-based with no CSV fallback, and the HubSpot import volume on large datasets runs into daily rate limits.
The data model mapping is clean — threads to tickets, messages to engagements, customers to contacts — but the fidelity gaps are predictable and specific: custom timeline entries flatten to text, inline images break if not re-uploaded, Slack channel attribution is lost without custom properties, Customer Events require Enterprise or get archived as notes, and tenant structure requires an architectural decision before extraction begins.
Know where the fidelity gaps are before you start. Get the staging database right, validate with a stratified test batch, and the full migration runs predictably. Skip the test batch and you find mapping bugs at scale — which cost more to fix than to prevent.
Frequently Asked Questions
- Can I export data from Plain as CSV for a HubSpot migration?
- No. Plain does not offer a bulk CSV export for threads or full conversation history. All data extraction must go through Plain's GraphQL API at core-api.uk.plain.com/graphql/v1. You need an API key with appropriate read permissions and a script that handles pagination and the 450-request window rate limit.
- How do Plain threads map to HubSpot tickets?
- Each Plain Thread becomes one HubSpot Ticket. Thread timeline messages (emails, chat, Slack) become HubSpot Engagement records associated with the ticket. Thread status maps to pipeline stages: Todo→Open, Snoozed→In Progress, Done→Closed. Labels map to tags or custom multi-select properties. Thread Fields become custom ticket properties.
- How do I migrate Plain Custom Timeline Entries to HubSpot?
- HubSpot does not support custom UI components in ticket timelines. Serialize Plain's componentJson into a human-readable text string and insert it into HubSpot as a standard Note engagement, prefixed with [SYSTEM EVENT] so agents understand its origin.
- How long does a Plain to HubSpot migration take?
- For under 10K threads with simple schemas, 1–2 weeks. For 10K–50K threads with thread fields and labels, 2–4 weeks. For 50K–200K threads with attachments and customer events, 4–6 weeks. These include planning, HubSpot setup, script development, test migration, and validation.
- What Plain data cannot be migrated to HubSpot?
- Customer Cards (live API data panels), AI agent config (Ari), automations and webhook workflows, saved views and queue config, and Linear/GitHub bidirectional issue links cannot be migrated. Customer Events and Tenant data have no native HubSpot equivalent on Professional plans — they require Enterprise features or must be archived.


