Skip to content

Plain to Tidio Migration: The Technical Guide

A complete technical guide to migrating from Plain to Tidio. Covers API mapping, data model differences, rate limits, edge cases, and step-by-step process.

Raaj Raaj · · 31 min read
Plain to Tidio Migration: The Technical Guide
TALK TO AN ENGINEER

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 Tidio Migration: The Technical Guide

Migrating from Plain to Tidio means moving from an API-first, GraphQL-native support platform built for B2B engineering teams to a REST-based live chat and helpdesk platform built for SMB and e-commerce. There is no native migration path between these two systems. Every record — customers, companies, tenants, threads, messages, labels, custom fields — requires extraction from Plain's GraphQL API, structural transformation, and loading through Tidio's REST OpenAPI or file import.

This is not a lift-and-shift. It is a data-model translation project, and most of the risk lives in that translation layer. Plain can hold richer account context than Tidio's flatter contact-and-ticket model exposes, so you need an explicit plan for flattening or externalizing that context.

This guide covers the full technical architecture: data model mapping, API constraints on both sides, step-by-step process, and the edge cases that will trip you up if you don't plan for them.

Disclosure: This guide is published by ClonePartner, a company that offers managed migration services. We have a commercial interest in one of the five migration approaches described below. We've aimed to provide technically accurate guidance regardless of which approach you choose.

Plain vs Tidio: Core Architecture Differences

Before writing any migration code, understand what you're translating between.

Plain is an API-first customer infrastructure platform for B2B SaaS teams. Its GraphQL API is the primary interface — everything you can do in the UI, you can do via the API. The core data model revolves around Threads (equivalent to tickets), Customers, Companies, Tenants, Labels, and Thread Fields (custom attributes). Threads have a timeline containing communications, events, and assignment changes. Thread statuses are Todo, Done, or Snoozed. Customers can belong to one Company and to multiple Tenants. Plain also exposes dedicated importThread and importThreadMessages GraphQL mutations specifically designed for bulk data ingestion — these are distinct from standard thread creation and support higher throughput for migration use cases. (plain.com)

Tidio is a live chat and customer service platform that merges chat, chatbots (Lyro AI), ticketing, and marketing automation into a single dashboard. It uses a REST-based OpenAPI for back-end integrations. The core objects are Contacts, Conversations, Tickets, and Operators. Tidio's data model is flatter and more contact-centric — it has no native equivalent to Plain's Company or Tenant objects, and no equivalent depth of per-thread field customization. (developers.tidio.com)

Dimension Plain Tidio
API Type GraphQL REST (OpenAPI)
Primary Work Object Thread Conversation / Ticket
Customer Object Customer (with Company + Tenants) Contact
Organization Object Company + Tenant None (use Contact Properties or Tags)
Custom Fields Thread Fields + Customer custom attrs Contact Properties + Ticket Custom Fields
Statuses Todo / Done / Snoozed Open / Pending / Solved
Auth Bearer token (API key) X-Tidio-Openapi-Client-Id + X-Tidio-Openapi-Client-Secret headers
Rate Limits Complexity-based (GraphQL); default budget ~250,000 complexity points per minute 60 req/min (Plus) / 120 req/min (Premium)
Bulk Import importThread / importThreadMessages mutations (designed for migration) CSV import (contacts only); JSONL import (tickets on Plus+); API for tickets
API Access Plans All plans Plus ($749/mo) or Premium ($2,999/mo) required
Warning

Do not assume a one-to-one model. Plain supports one company per customer, multiple tenants per customer, hierarchical labels (up to three levels), multi-assignee threads, and threads that can contain multiple email threads and participants. Tidio's model is much flatter. If you skip the design step, you will lose reporting context even if every row technically imports.

Why Teams Migrate from Plain to Tidio

The migration typically reflects a change in business model or team composition rather than a feature comparison:

  • Business model shift: Moving from technical B2B SaaS (where Plain's API-first model fits) to high-volume B2C or e-commerce where live chat, AI chatbot deflection, and marketing automation (Tidio's Lyro AI, chatbot flows, email campaigns) are more valuable than deep API extensibility.
  • E-commerce stack alignment: Tidio has native Shopify, WooCommerce, and WordPress integrations. Plain is built for developer-tool companies and B2B SaaS with no e-commerce connectors.
  • Team composition change: Support teams without dedicated engineering resources find Plain's API-first model requires ongoing development investment that Tidio's visual configuration eliminates.

The core technical challenge is flattening Plain's multi-dimensional GraphQL schema (Companies → Tenants → Customers → Threads → Timeline Entries) into Tidio's simplified REST model (Contacts → Tickets → Messages).

Migration Approaches

There are five viable methods. The right choice depends on your data volume, engineering bandwidth, and whether you need conversation history in Tidio.

1. CSV/JSONL File Import

How it works: Export customer data from Plain via GraphQL queries, transform contacts to CSV and tickets to JSONL, then use Tidio's built-in importers. Tidio's contact import requires UTF-8 CSV/TXT files. Tidio's ticket import uses JSONL format on Plus plans and above, supports files up to 1 GB, and accepts external ticket data from any source. (help.tidio.com)

JSONL ticket schema: Each line in the JSONL file must be a valid JSON object. Based on Tidio's documented import format, the required structure is:

{
  "subject": "Thread title from Plain",
  "requester": {"email": "customer@example.com", "name": "Customer Name"},
  "status": "solved",
  "created_at": "2024-01-15T10:30:00Z",
  "messages": [
    {
      "body": "<p>HTML message content</p>",
      "author_email": "customer@example.com",
      "created_at": "2024-01-15T10:30:00Z",
      "type": "public"
    }
  ],
  "tags": ["billing", "urgent"],
  "attachments": [
    {"url": "https://your-cdn.com/file.pdf", "filename": "file.pdf"}
  ]
}

Attachment URLs must be publicly accessible at import time — Tidio downloads them during processing. There is no documented size limit per individual attachment, but the total JSONL file must stay under 1 GB. (help.tidio.com)

When to use it: One-time migration with modest data volume and acceptable model flattening.

Pros: Lowest code surface on the import side. Fastest pilot. Cons: Plain has no self-serve export — you still need scripting on the source side. CSV only handles contacts. JSONL ticket import has its own schema requirements. Scalability: Fine for up to ~50K contacts. Larger datasets may need batch splitting. Complexity: Low to Medium.

Warning

Tidio's CSV import only handles contact data. There is no CSV import path for tickets, conversations, or message history. For ticket history, you need the JSONL import (Plus plan required) or the API.

2. API-Based Migration (Plain GraphQL → Tidio REST API)

How it works: Build a custom script that extracts data from Plain's GraphQL API and writes it to Tidio's REST OpenAPI. This is the most flexible method for migrating both contacts and full conversation/ticket history.

Plain provides two migration-specific mutations that are critical here:

  • importThread — Creates a thread with historical metadata (timestamps, status, assignee) preserved. Unlike createThread, this mutation lets you set created_at and other fields that would otherwise be auto-generated.
  • importThreadMessages — Appends messages to an imported thread with preserved timestamps. This is designed for bulk historical data loading.

These mutations are the recommended extraction-side complement when you need to stage or transform data before loading into Tidio.

When to use it: You need to preserve conversation history, ticket metadata, and contact properties with full control over mapping and validation.

Pros: High-fidelity migration. Handles contacts, conversations, tickets, and custom properties. Scriptable and repeatable. Cons: Requires development effort. Rate limits on both sides constrain throughput. Tidio's POST /contacts endpoint always creates a new contact — it does not merge by email or distinct_id. You must query by email first to avoid duplicates. Tidio's documented ticket reply endpoint does not expose attachment fields, making attachment-heavy history harder over pure API. (developers.tidio.com) Scalability: Works for any data volume with proper batching and rate-limit handling. At 120 req/min on Tidio Premium, migrating 10K conversations with 5 messages each takes ~7 hours of sustained API time (assuming no retries). Complexity: High.

3. Managed Migration Service

How it works: A managed service handles the full extraction, transformation, and loading process, building custom scripts and managing API constraints.

When to use it: You don't have engineering bandwidth to build and maintain migration scripts, your data has complex multi-level relationships, or you can't afford extended downtime for debugging.

Pros: No engineering time from your team. Handles edge cases and rate limits. Cons: Cost (typically $2K–$15K depending on data volume and complexity). Less control over exact transformation logic. Scalability: Purpose-built for datasets of any size. Complexity: Low (for you).

4. Custom ETL Pipeline

How it works: Build a formal Extract-Transform-Load pipeline using a framework like Apache Airflow, Prefect, or a custom Node.js/Python pipeline. Extract from Plain, stage in a database (PostgreSQL, SQLite), apply transformations, and load into Tidio.

When to use it: Large datasets (100K+ threads), complex transformation logic, or when you need an audit trail and the ability to re-run specific stages independently.

Pros: Full control. Auditable. Stages can be re-run independently. Intermediate staging database enables validation queries before loading. Cons: Highest engineering effort (estimate 80–160 hours for a team unfamiliar with both APIs, based on the need to handle schema mapping, pagination, rate limiting, error recovery, and validation for each object type). Overkill for standard helpdesk migrations. Tidio plan limits and rate limits still apply on load. Scalability: Best option for enterprise-scale data. Complexity: Very High.

5. Middleware Platforms (Zapier, Make)

How it works: Use workflow automation to connect Plain webhooks or scheduled queries to Tidio's API. Make has a community-developed Tidio app.

When to use it: Ongoing sync of new records after initial migration, not historical backfill.

Pros: No-code setup. Good for ongoing sync after the bulk migration. Cons: Not suitable for bulk historical migration. Task-based pricing makes large backfills cost-prohibitive (e.g., 50K records at Make's standard pricing = ~$200–500/month in task costs). Cannot handle complex transformations or multi-level relationship rebuilding. Easily hits API rate limits. (plain.mintlify.app) Scalability: Poor for bulk migration. Fine for <100 records/day ongoing sync. Complexity: Medium.

Approach Comparison

Method Contacts Tickets/History Complexity Engineering Hours Best For
CSV/JSONL Import JSONL on Plus only Low/Medium 8–20 One-time migration, simple data
API-Based High 40–80 Full migration with dev team
Managed Service Low (for you) 2–5 (coordination) Complex data, no eng bandwidth
Custom ETL Very High 80–160 Enterprise scale, audit needs
Middleware Partial Medium 10–20 Ongoing sync, not bulk

Recommendations by scenario:

  • Small team, contacts only: CSV import and start fresh with tickets.
  • Small team, full history: Managed migration service or JSONL import.
  • Mid-size, dedicated dev team: API-based migration script.
  • Enterprise, 100K+ records: Custom ETL pipeline or managed service.
  • Ongoing sync post-migration: Middleware (Make/Zapier) for new records.

Pre-Migration Planning

Data Audit Checklist

Before writing any code, inventory everything in Plain:

  • Customers: Total count, active vs. inactive, custom attributes in use, email/phone coverage
  • Companies: Count, customers per company, company-level custom attributes
  • Tenants: Count, customer-to-tenant membership mapping (one customer can belong to multiple tenants)
  • Threads: Total count by status (Todo/Done/Snoozed), date range, average messages per thread
  • Timeline Entries: Identify entry types present in your data. Plain's timeline entry types include: EmailEntry, ChatEntry, NoteEntry, CustomEntry, StatusChangedEntry, AssignmentChangedEntry, LabelsChangedEntry, PriorityChangedEntry, and integration-specific types like LinearIssueLinkStateTransitionedEntry. Only EmailEntry, ChatEntry, NoteEntry, and CustomEntry contain user-generated content worth migrating.
  • Attachments: Count and total size. Note that Plain uses signed URLs with expiration — you must download files during extraction.
  • Labels: All label names, hierarchy depth (up to 3 levels), and usage counts
  • Thread Fields: All custom field definitions (type, required/optional, values)
  • Users (agents): All workspace users who need Operator accounts in Tidio
  • Integrations: Linear, Jira, Slack connections that need to be rebuilt
  • Adjacent CRM data: Accounts, contacts, leads, or opportunities surfaced through tenant fields or customer cards that your support team relies on daily

What to Leave Behind

Not everything needs to migrate:

  • Threads older than 12–24 months with no active context — archive these instead
  • Spam or test threads — filter these out before migration
  • Orphaned customers with no threads — evaluate if they belong in Tidio's contact list
  • Integration-specific data (Linear issue links, webhook logs) — these can't be migrated to Tidio
  • System timeline entries (assignment changes, status transitions, API events) — these create noise in Tidio and have no equivalent representation
  • Dead labels, stale tenants, hidden fields, and custom metadata nobody uses anymore

Migration Strategy

Strategy When to Use Risk Level
Big bang Small dataset (<5K threads), short cutover window acceptable Medium
Phased with delta sync Large dataset, migrate by date range or department Low
Incremental Ongoing sync needed, new data flows continuously Low

For most Plain-to-Tidio migrations, a phased approach with delta sync works best:

  1. Extract and load all historical data up to a specific freeze date.
  2. Allow agents to test the data in Tidio.
  3. Run a delta sync to capture any new Plain threads created during the testing phase.
  4. Route DNS/email to Tidio and deprecate Plain.

Data Model & Object Mapping

This is where the migration gets real. Plain and Tidio have fundamentally different data models.

Object-Level Mapping

Plain Object Tidio Equivalent Notes
Customer Contact Direct mapping. Email is the primary key in both.
Company (No direct equivalent) Store as Contact Property (company_name). Tidio has no Company/Organization object.
Tenant (No direct equivalent) Store tenant memberships as Contact Property (tenant_ids). Customers can belong to multiple tenants.
Thread Ticket Map 1:1. Store Plain Thread ID in a Tidio custom field for audit trail and cross-reference.
Thread Timeline (messages) Ticket Messages Flatten timeline entries to messages. Exclude system events (see entry type list above). Map emails to public messages, notes to internal messages.
Labels Tags Map label names to Tidio tags. Flatten hierarchy: a Plain label billing > refunds > disputes becomes three Tidio tags or one concatenated tag billing__refunds__disputes. Create tags in Tidio before import.
Thread Fields Contact Properties or Ticket Custom Fields Tidio ticket custom fields are limited to text and dropdown types. Move thread-level metadata to Contact Properties or append to ticket notes for richer field types.
Users (agents) Operators Must be created manually in Tidio. The API does not support Operator creation.
SLA Policies (Manual rebuild) No API migration path. Rebuild in Tidio settings.
Warning

Company and Tenant data loss risk: Plain organizes customers under Companies (one per customer) and Tenants (multiple per customer). Tidio has no equivalent entities. You must flatten company and tenant data into contact properties or accept that this hierarchy is lost. If B2B reporting depends on company grouping, keep an external CRM as the source of truth.

Field-Level Mapping

Plain Field Type Tidio Field Type Transformation
customer.fullName String contact.name String Direct
customer.email.email String contact.email String Primary dedup key. Query GET /contacts?email= before POST /contacts to prevent duplicates.
customer.phone String contact.phone String Direct
customer.externalId String contact.distinct_id String Direct — but distinct_id is limited to 55 characters. Truncate with logging if exceeded.
customer.company.name String contact.properties.company_name Custom Property Requires pre-creating the property in Tidio (type: text).
customer.tenants [].name String [] contact.properties.tenant_names Custom Property Concatenate with delimiter (e.g., `Tenant A
thread.title String ticket.subject String Direct
thread.status Enum ticket.status String TODOopen, DONEsolved, SNOOZEDpending
thread.statusDetail Enum ticket.properties.status_detail Custom Property Preserve WAITING_FOR_CUSTOMER, IN_PROGRESS, etc. for analytics granularity.
thread.priority Int (0–3) ticket.priority String 0urgent, 2normal, 3low. Plain's 1 (high) has no Tidio equivalent — map to urgent or preserve original value in a custom field plain_priority.
thread.labels [].name String [] ticket.tags String [] Flatten nested labels. A label path billing > refunds becomes tag billing__refunds or two separate tags.
thread.createdAt ISO 8601 ticket.created_at ISO 8601 Must explicitly set this via the API. If omitted, Tidio stamps the current date, destroying historical timeline accuracy.
thread.assignee.email String ticket.operator_id ID Look up Tidio operator ID by email. Build a lookup table during Step 1.
timeline.EmailEntry (inbound) Object ticket.message Object Extract body text. Convert Markdown to HTML. Set type: "public".
timeline.EmailEntry (outbound) Object ticket.message Object Map sender to operator. Set type: "public".
timeline.ChatEntry Object ticket.message Object Direct body extraction. Set type: "public".
timeline.NoteEntry Object Internal ticket message Object Set type: "internal". Preserve author attribution.
timeline.CustomEntry JSON Object Internal ticket message Object Parse JSON payload into human-readable text block. Append as internal note with prefix [Custom Event].
Thread Field (custom) Various contact.properties.{key} Custom Property Type conversion required: Plain boolean → Tidio text ("true"/"false"), Plain enum → Tidio dropdown (values must be pre-defined).
Warning

Custom timeline entries: Plain allows highly flexible custom timeline entries via its API, often containing structured JSON payloads. Tidio tickets are strictly conversational. You must parse any JSON-based custom timeline entries in Plain and convert them into human-readable text blocks appended as internal notes in Tidio.

Handling Relationships

Plain's data model supports:

  • Customer → Company (many-to-one)
  • Customer → Tenants (many-to-many)
  • Thread → Customer (many-to-one)
  • Thread → Labels (many-to-many)
  • Thread → Thread Fields (one-to-many)
  • Timeline → Thread (one-to-many)

Tidio's model is flatter:

  • Contact → Conversations (one-to-many)
  • Contact → Properties (one-to-many)
  • Conversation → Messages (one-to-many)

The key challenge is that Plain's Company/Tenant → Customer hierarchy doesn't exist in Tidio. You must flatten it into contact properties. If your support team uses company-level reporting (e.g., "all open tickets for Acme Corp"), you need an external system (CRM, data warehouse) to maintain that grouping post-migration.

Migration Architecture

The overall data flow follows a standard ETL pattern:

┌─────────────┐     ┌──────────────┐     ┌─────────────┐
│   EXTRACT   │────▶│  TRANSFORM   │────▶│    LOAD     │
│ Plain GQL   │     │ JSON → JSON  │     │ Tidio REST  │
│ API         │     │ + HTML conv  │     │ API + CSV   │
│             │     │ + staging DB │     │ + JSONL     │
└─────────────┘     └──────────────┘     └─────────────┘

Extract: Plain GraphQL API

Endpoint: https://core-api.uk.plain.com/graphql/v1 (UK region; US region uses core-api.us.plain.com)

Authentication: Bearer token via API key (generated from Settings → API Keys in your Plain workspace).

Rate limiting: Plain uses complexity-based rate limiting. Each query field has a complexity cost, and you have a budget per minute (default ~250,000 points). A simple customers(first: 50) query with basic fields costs roughly 50–100 points. Adding nested selections (company, tenants, threads) increases cost significantly. If you exceed the budget, the API returns a 429 with a Retry-After header. Keep queries focused and avoid deeply nested selections.

Paginate using cursor-based pagination (after parameter). Fetch threads in batches of 25–50.

# Fetch customers with pagination
query GetCustomers($after: String) {
  customers(first: 50, after: $after) {
    edges {
      node {
        id
        fullName
        email { email }
        phone
        externalId
        company { id name }
        tenantMemberships {
          edges {
            node {
              tenant { id name }
            }
          }
        }
        customAttributes { key value }
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}
 
# Fetch threads with timeline entries (messages only)
query GetThreads($after: String) {
  threads(first: 25, after: $after) {
    edges {
      node {
        id
        externalId
        title
        status
        statusDetail
        priority
        createdAt
        updatedAt
        customer { id email { email } }
        assignee { ... on UserAssignee { user { email } } }
        labels { name }
        threadFields { key value }
        timelineEntries(first: 100) {
          edges {
            node {
              ... on EmailEntry {
                emailId
                from { email name }
                to { email name }
                subject
                textContent
                htmlContent
                sentAt
                attachments { id fileName fileSize }
              }
              ... on ChatEntry {
                chatId
                text
                sentAt
              }
              ... on NoteEntry {
                noteId
                text
                createdAt
                createdBy { email }
              }
              ... on CustomEntry {
                title
                components
                createdAt
              }
            }
          }
        }
      }
    }
    pageInfo {
      hasNextPage
      endCursor
    }
  }
}

Attachment handling: Plain returns signed URLs for attachments. These URLs expire (typically within hours). Your extraction script must download attachment files immediately and store them locally or upload them to a stable hosting location (S3, GCS) before the URLs expire.

Transform

The transformation layer handles:

  1. Status mapping: TODOopen, DONEsolved, SNOOZEDpending. Preserve Plain's statusDetail (e.g., WAITING_FOR_CUSTOMER, IN_PROGRESS) in a custom field if you need analytics granularity.
  2. Company/Tenant flattening: Extract company name and tenant memberships into contact-level properties.
  3. Label → Tag conversion: Flatten nested label hierarchies into discrete tag names. For a three-level hierarchy like billing > refunds > disputes, decide between: (a) three separate tags, (b) a concatenated tag billing__refunds__disputes, or (c) both approaches with the full path stored in an internal note for searchability.
  4. Markdown to HTML: Plain stores messages in Markdown or raw text. Tidio expects HTML for rich text rendering. Use a library like marked (Node.js) or markdown2 (Python) to convert. Sanitize output to prevent XSS if your Tidio instance is customer-facing.
  5. Timeline filtering: Extract only entries of type EmailEntry, ChatEntry, NoteEntry, and CustomEntry from thread timelines. Discard StatusChangedEntry, AssignmentChangedEntry, LabelsChangedEntry, PriorityChangedEntry, and integration-specific entries (e.g., LinearIssueLinkStateTransitionedEntry). These system events create noise in Tidio and have no equivalent representation.
  6. CustomEntry parsing: For CustomEntry timeline items, extract the title and components fields. Convert the structured JSON components into a readable text block: [Custom Event: {title}]\n{formatted components}. Append as an internal note.
  7. Timestamp normalization: Both use ISO 8601, but verify timezone handling. Plain stores timestamps in UTC. Ensure your transformation preserves UTC or converts consistently.
  8. Attachment staging: Download files from Plain's signed URLs during extraction. Re-upload to a stable public URL (S3 bucket with public read, GCS signed URL with long expiry). For JSONL import, Tidio downloads from these URLs during processing. For API import, Tidio's documented ticket reply endpoint does not expose attachment fields — use the JSONL path for attachment-heavy history.

Load: Tidio REST API

Base URL: https://api.tidio.co

Authentication: Three headers required on every request:

X-Tidio-Openapi-Client-Id: YOUR_CLIENT_ID
X-Tidio-Openapi-Client-Secret: YOUR_CLIENT_SECRET
Accept: application/json; version=1

Credentials are generated in the Tidio panel under Settings → Developer → OpenAPI. (developers.tidio.com)

Rate limits: 60 requests/minute on Plus, 120 requests/minute on Premium. Free and Starter plans do not include OpenAPI access. When you exceed the limit, Tidio returns HTTP 429 with a response body: {"error": "Rate limit exceeded", "retry_after": <seconds>}. The X-RateLimit-Remaining and X-RateLimit-Reset headers are included on every response for proactive tracking. (developers.tidio.com)

Danger

Rate limits are strict. At 120 req/min on Premium, you get 2 requests per second. A migration of 10K contacts + 10K conversations + 50K messages requires ~70K API calls minimum — roughly 10 hours of sustained API time assuming zero retries. In practice, expect 12–15 hours accounting for retries, dedup lookups, and error handling. Build retry logic with exponential backoff for 429 responses. Track the X-RateLimit-Remaining header proactively and throttle before hitting the limit.

Loading order matters:

  1. Operators — created manually in Tidio dashboard (API does not support creation)
  2. Departments — created manually
  3. Contact Properties — created via dashboard or API
  4. Contacts — via POST /contacts (query by email first to prevent duplicates)
  5. Tickets — via API or JSONL import (reference contact IDs from step 4)
  6. Messages — appended to tickets (reference ticket IDs from step 5)

Step-by-Step Migration Process

Step 1: Prepare the Tidio Environment

Before importing any data:

  1. Create all Operators manually — Tidio's API does not support Operator creation. Each agent must log into Tidio at least once to generate their operator ID.
  2. Create all Departments manually — same limitation.
  3. Define all Contact Properties that correspond to Plain's custom customer attributes, company data, and tenant fields. Critical: Tidio contact property types cannot be changed after creation, and existing properties cannot be deleted — only hidden. Define your schema on a test project first and validate before creating in production. (help.tidio.com)
  4. Define Ticket Custom Fields where needed (limited to text and dropdown types).
  5. Confirm your Tidio plan includes API access — OpenAPI requires Plus ($749/mo) or Premium ($2,999/mo).
  6. Build an operator lookup table — Map Plain user emails to Tidio operator IDs. You'll need this for ticket assignment during load.
  7. Set up a test project to validate scripts before running against production.

Step 2: Extract Data from Plain

import requests
import json
import time
 
PLAIN_API_URL = "https://core-api.uk.plain.com/graphql/v1"
PLAIN_API_KEY = "your_plain_api_key"
 
def query_plain(query, variables=None):
    response = requests.post(
        PLAIN_API_URL,
        headers={
            "Content-Type": "application/json",
            "Authorization": f"Bearer {PLAIN_API_KEY}"
        },
        json={"query": query, "variables": variables or {}}
    )
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 10))
        print(f"Plain rate limit hit. Waiting {retry_after}s...")
        time.sleep(retry_after)
        return query_plain(query, variables)  # Retry
    response.raise_for_status()
    result = response.json()
    if "errors" in result:
        raise Exception(f"GraphQL errors: {result['errors']}")
    return result
 
def extract_all_customers():
    customers = []
    cursor = None
    while True:
        result = query_plain("""
            query($after: String) {
                customers(first: 50, after: $after) {
                    edges { node { id fullName email { email } phone externalId
                        company { id name }
                        tenantMemberships { edges { node { tenant { id name } } } }
                        customAttributes { key value } } }
                    pageInfo { hasNextPage endCursor }
                }
            }
        """, {"after": cursor})
        edges = result["data"]["customers"]["edges"]
        customers.extend([e["node"] for e in edges])
        page_info = result["data"]["customers"]["pageInfo"]
        if not page_info["hasNextPage"]:
            break
        cursor = page_info["endCursor"]
        print(f"Extracted {len(customers)} customers so far...")
    return customers

Store raw JSON exports as backup files before any transformation. Use timestamped filenames for traceability.

Step 3: Transform Data

import markdown2
 
def transform_customer_to_tidio_contact(plain_customer):
    contact = {
        "name": plain_customer.get("fullName", ""),
        "email": plain_customer.get("email", {}).get("email", ""),
        "phone": plain_customer.get("phone", ""),
        "properties": {}
    }
    # Flatten company into contact property
    if plain_customer.get("company"):
        contact["properties"]["company_name"] = plain_customer["company"]["name"]
 
    # Flatten tenant memberships
    tenants = plain_customer.get("tenantMemberships", {}).get("edges", [])
    if tenants:
        tenant_names = [t["node"]["tenant"]["name"] for t in tenants]
        contact["properties"]["tenant_names"] = " | ".join(tenant_names)
 
    # Preserve external ID (max 55 chars for distinct_id)
    ext_id = plain_customer.get("externalId", "")
    if ext_id:
        if len(ext_id) > 55:
            print(f"WARNING: Truncating externalId '{ext_id}' to 55 chars")
        contact["distinct_id"] = ext_id[:55]
 
    # Map custom attributes
    for attr in plain_customer.get("customAttributes", []):
        contact["properties"][f"plain_{attr['key']}"] = str(attr["value"])
 
    return contact
 
def transform_thread_status(plain_status):
    mapping = {
        "TODO": "open",
        "DONE": "solved",
        "SNOOZED": "pending"
    }
    return mapping.get(plain_status, "open")
 
def transform_priority(plain_priority):
    """Plain: 0=urgent, 1=high, 2=normal, 3=low
       Tidio: urgent, normal, low (no 'high')"""
    mapping = {0: "urgent", 1: "urgent", 2: "normal", 3: "low"}
    return mapping.get(plain_priority, "normal")
 
MIGRATABLE_ENTRY_TYPES = {"EmailEntry", "ChatEntry", "NoteEntry", "CustomEntry"}
 
def filter_timeline_entries(timeline_entries):
    """Filter to only user-generated content. Discard system events."""
    return [e for e in timeline_entries if e.get("__typename") in MIGRATABLE_ENTRY_TYPES]
 
def markdown_to_html(text):
    """Convert Plain's Markdown messages to HTML for Tidio."""
    if not text:
        return ""
    return markdown2.markdown(text, extras=["fenced-code-blocks", "tables"])

Step 4: Load into Tidio

import time
import sqlite3
 
TIDIO_API_URL = "https://api.tidio.co"
TIDIO_CLIENT_ID = "your_client_id"
TIDIO_CLIENT_SECRET = "your_client_secret"
 
# Track ID mappings for referential integrity
db = sqlite3.connect("migration_state.db")
db.execute("CREATE TABLE IF NOT EXISTS contact_map (plain_id TEXT PRIMARY KEY, tidio_id TEXT, email TEXT)")
db.execute("CREATE TABLE IF NOT EXISTS ticket_map (plain_id TEXT PRIMARY KEY, tidio_id TEXT)")
 
def tidio_request(method, endpoint, data=None):
    for attempt in range(5):
        response = requests.request(
            method,
            f"{TIDIO_API_URL}{endpoint}",
            headers={
                "Content-Type": "application/json",
                "Accept": "application/json; version=1",
                "X-Tidio-Openapi-Client-Id": TIDIO_CLIENT_ID,
                "X-Tidio-Openapi-Client-Secret": TIDIO_CLIENT_SECRET
            },
            json=data
        )
        remaining = response.headers.get("X-RateLimit-Remaining")
        if remaining and int(remaining) < 5:
            time.sleep(2)  # Proactive throttle
 
        if response.status_code == 429:
            retry_after = int(response.headers.get("Retry-After", 5))
            wait = max(retry_after, 5 * (attempt + 1))
            print(f"Rate limited (attempt {attempt+1}). Waiting {wait}s...")
            time.sleep(wait)
            continue
        response.raise_for_status()
        return response.json()
    raise Exception(f"Failed after 5 retries: {method} {endpoint}")
 
def find_existing_contact(email):
    """Check if contact already exists to prevent duplicates."""
    result = tidio_request("GET", f"/contacts?email={email}")
    contacts = result.get("data", [])
    return contacts[0]["id"] if contacts else None
 
def create_tidio_contact(contact_data, plain_id):
    # Check for existing contact first (POST /contacts always creates new)
    email = contact_data.get("email", "")
    existing_id = find_existing_contact(email) if email else None
 
    if existing_id:
        print(f"Contact {email} already exists (ID: {existing_id}), skipping creation")
        db.execute("INSERT OR REPLACE INTO contact_map VALUES (?, ?, ?)",
                   (plain_id, existing_id, email))
        db.commit()
        return existing_id
 
    result = tidio_request("POST", "/contacts", contact_data)
    tidio_id = result["id"]
    db.execute("INSERT OR REPLACE INTO contact_map VALUES (?, ?, ?)",
               (plain_id, tidio_id, email))
    db.commit()
    return tidio_id

Data flow:

  1. Fetch all Plain Customers → Create Tidio Contacts (store ID mappings in SQLite)
  2. Fetch Plain Threads → Create Tidio Tickets (use stored contact mappings for assignment)
  3. Fetch Plain Timeline Entries → Filter to migratable types → Append as Messages to Tidio Tickets
  4. For each timeline entry: push EmailEntry and ChatEntry as public messages, NoteEntry as internal messages, CustomEntry as internal notes with parsed content

Step 5: Validate

  • Compare total customer count in Plain vs. contact count in Tidio
  • Spot-check 5–10% of records for field-level accuracy (use the SQLite mapping DB to select random pairs)
  • Verify conversation message counts match thread timeline entry counts (after system event filtering)
  • Check that custom properties populated with correct values and types
  • Confirm no duplicate contacts were created (query Tidio contacts by email and check for count > 1)
  • Verify timestamps — confirm created_at on tickets matches Plain's thread.createdAt, not the migration date
  • Validate attachment accessibility in migrated tickets

Edge Cases & Challenges

Duplicate Contacts

Tidio's POST /contacts endpoint always creates a new contact. It does not deduplicate by email or distinct_id. If you call it twice with the same email, you get two contacts. Always query contacts by email before creation using GET /contacts?email=, or use Tidio's native CSV importer for the initial contact load (which does support update-by-email behavior). The dedup query adds one API call per contact, which doubles the contact creation time but prevents a data integrity problem that is painful to fix after the fact. (developers.tidio.com)

Attachments

Plain secures attachments behind signed URLs that expire (typically within hours of generation). Your migration script must:

  1. Download files from Plain's signed URLs during the extraction phase
  2. Upload them to a stable public URL (e.g., S3 bucket with public-read ACL, or GCS with long-lived signed URLs)
  3. Reference those stable URLs in the Tidio JSONL import payload or ticket messages

The Tidio ticket reply API endpoint does not expose attachment fields, making the JSONL import the more reliable path for attachment-heavy history. JSONL attachment entries require url (publicly accessible) and filename fields. There are no documented per-file size limits in Tidio's JSONL import, but the total JSONL file must stay under 1 GB. (help.tidio.com)

Multi-Tenant Customers

In Plain, one customer can belong to multiple Tenants. In Tidio, a Contact is a flat record. Options:

  • Concatenate tenant names into a single contact property: "Tenant A | Tenant B | Tenant C"
  • Store the thread's relevant tenant on each ticket as a tag or custom field (requires per-thread lookup during extraction)
  • Maintain a separate mapping table in your CRM or data warehouse for tenant-level reporting

Do not create duplicate contacts per tenant. The customer-to-contact mapping must be 1:1.

Multi-Participant and Multi-Assignee Threads

Plain threads can contain multiple email participants (To, CC, BCC) and can be assigned to multiple users or machine users simultaneously. Tidio's ticket model centers on a single contact and a single operator assignment.

Contacts: Pick the thread's primary customer as the Tidio contact. Store additional participants (CC'd emails) in an internal note on the ticket: "Additional participants: alice@example.com, bob@example.com".

Assignees: Flatten multi-assignee to the last human assignee. If the thread is assigned to a machine user (Plain's AI/automation concept), assign to the default Tidio operator or leave unassigned.

Thread Fields (Custom Fields)

Plain's thread fields are per-thread and support types including text, boolean, enum, and number. Tidio's ticket custom fields are limited to text and dropdown types. Strategies:

  • Text fields: Direct migration to Tidio ticket custom fields.
  • Boolean fields: Convert to text ("true"/"false") or dropdown ("Yes"/"No").
  • Enum fields: Map to Tidio dropdown — pre-define all possible values before import.
  • Number fields: Convert to text. Tidio does not support numeric custom fields on tickets.
  • Thread-level fields needed at contact level: If a field like plan_tier applies to the customer rather than the thread, move it to a contact property instead.

System Events Noise

Plain logs system events in the timeline: assignment changes (AssignmentChangedEntry), status transitions (StatusChangedEntry), label changes (LabelsChangedEntry), priority changes (PriorityChangedEntry), and integration events (LinearIssueLinkStateTransitionedEntry). Migrating these creates massive noise in Tidio — a thread with 5 messages might have 30 timeline entries. Filter to only EmailEntry, ChatEntry, NoteEntry, and CustomEntry types.

Contact Property Schema Lock-in

Tidio contact property types (text, number, dropdown, date, boolean) cannot be changed after creation, and existing properties cannot currently be deleted — only hidden. Get your schema right before the first test load. Test on a throwaway Tidio project first. If you create company_name as a dropdown instead of text, you cannot fix it without creating a new property company_name_v2. (help.tidio.com)

Multi-Channel Threads

Plain supports email, chat, Slack, and custom channels within a single thread. Tidio organizes conversations by channel (live chat, email/helpdesk, Messenger, Instagram). Map each Plain thread to the appropriate Tidio channel based on its origin. If a Plain thread contains mixed-channel entries (e.g., started as email, continued in Slack), import as an email ticket with a note indicating the original channel mix.

HTML vs. Markdown Content

Plain may store message content as Markdown, raw text, or HTML depending on the entry type (textContent vs. htmlContent on EmailEntry). Tidio expects HTML. Your transformation must:

  • Use htmlContent when available (email entries)
  • Convert textContent from Markdown to HTML using a library
  • Sanitize output HTML (strip <script> tags, unsafe attributes)
  • Preserve code blocks and tables for technical support contexts

Limitations & Constraints

Be explicit with your stakeholders about what cannot be migrated:

Capability Plain Tidio Impact
Company objects Native None Company hierarchy lost; flatten to contact properties
Tenant hierarchy Native (multi-tenant) None Flattened to contact properties; no multi-tenant reporting
Per-thread custom fields Thread Fields (text, boolean, enum, number) Ticket custom fields (text, dropdown only) Field type reduction; boolean/number lose native type
GraphQL API Full support None All integrations must be rewritten for REST
SLA policies Configurable via API Manual setup only Cannot migrate programmatically
Linear/Jira links Native integration No equivalent Issue tracker links lost
Priority granularity 0–3 (urgent/high/normal/low) urgent/normal/low only No native "high" priority; merge into urgent or preserve in custom field
Webhook depth Granular event types (30+ event types) Limited event types Some automation triggers unavailable
AI agent infrastructure Machine users, custom models Lyro AI (managed, trained on your docs) Different AI architecture; no migration path
API-first routing Route via complex JSON payloads Visual chatbot flows (Lyro) Routing rules must be rebuilt from scratch
Multi-assignee Multiple users per thread Single operator per ticket Must flatten to primary assignee
Thread participants Multiple email participants (To/CC/BCC) Single contact per ticket Secondary participants preserved only in notes

Tidio's rate limits are the primary throughput constraint. At 120 requests per minute on Premium, large migrations require careful scheduling and may need to run overnight or over a weekend.

Validation & Testing

Never assume a 200 OK response means the data is correct.

Record Count Reconciliation

Plain customers exported:    ____
Tidio contacts created:      ____
Delta:                        ____ (investigate any mismatch)

Plain threads exported:      ____
System events filtered:      ____
Tidio tickets created:       ____
Delta:                        ____ (should equal filtered count)

Plain messages extracted:    ____ (after system event filtering)
Tidio messages created:      ____
Delta:                        ____

Field-Level Validation

Sample 5–10% of records and verify:

  • Name, email, phone match exactly
  • Custom properties populated with correct values and types
  • company_name and tenant_names properties contain expected values
  • Conversation message count matches thread timeline entry count (after system event filtering)
  • Timestamps preserved correctly — created_at should not be the migration date
  • Thread statuses mapped correctly (TODOopen, etc.)
  • Priority values mapped correctly (check priority=1 handling)
  • Tags match Plain label names
  • Attachment URLs are accessible in Tidio tickets

UAT Process

  1. Run the full migration against a test Tidio project first
  2. Have 2–3 support agents review their assigned conversations for accuracy
  3. Verify search works — can agents find customers by email, name, company property?
  4. Test any Tidio automations (chatbot flows, routing rules) against migrated data
  5. Verify that created_at timestamps display correctly in Tidio's conversation timeline
  6. Document gaps and define acceptance criteria before the production run

Rollback Plan

Tidio doesn't have a one-click "undo import" function. Your rollback plan:

  • Keep Plain active and read-only during migration
  • If Tidio import fails validation, delete the test project and start fresh
  • For production: maintain Plain as the system of record until Tidio is fully validated
  • Maintain the SQLite mapping database indefinitely — it's your cross-reference between old and new IDs

Post-Migration Tasks

Rebuild in Tidio

These cannot be migrated and must be manually configured:

  • Chatbot flows — Tidio's visual flow builder replaces Plain's API-driven automations
  • SLA policies — reconfigure response time targets in Tidio's settings
  • Email forwarding — connect external mailboxes (Gmail, Outlook) to Tidio's helpdesk
  • Routing rules — set up department-based or topic-based routing
  • Canned responses — recreate saved reply templates
  • Lyro AI — if using Tidio's AI chatbot, point it to your public documentation to build its knowledge graph. Lyro does not import training data from Plain.
  • Webhooks — rebuild any webhook-based integrations (CRM sync, Slack notifications) using Tidio's event types
  • Custom integrations — any integration that relied on Plain's GraphQL API must be rewritten for Tidio's REST API

Team Onboarding

  • Train agents on Tidio's conversation panel vs. Plain's thread-based UI
  • Document the new workflow for handling tickets, tags, and contact properties
  • Explain where company and tenant data now lives (contact properties vs. former dedicated objects)
  • Set up Tidio's mobile app for agents who need on-the-go access

Monitor Post-Migration

For the first 2 weeks after cutover:

  • Watch for duplicate contacts appearing from live chat widget interactions
  • Monitor Tidio's conversation metrics against Plain's historical baselines
  • Track any missing data reported by support agents
  • Verify webhook-based integrations (CRM sync, Slack notifications) are firing correctly
  • Check that the company_name and tenant_names contact properties are being used correctly in any reports or filters

Best Practices

  1. Back up everything first. Export all Plain data to timestamped JSON files before starting. Store these alongside your migration logs and mapping database.
  2. Run test migrations. Never run against production first. Use a test Tidio project and a subset of Plain data. Validate the contact property schema on the test project before creating it in production (properties are immutable once created).
  3. Validate incrementally. Don't wait until the full migration is done to check data. Validate after each batch of 100–500 records.
  4. Respect rate limits proactively. Track the X-RateLimit-Remaining header and throttle before you hit 429 errors. Build in delays before the limit, not after.
  5. Log everything. Every API call, every response, every error. Include source ID, target ID, attempt number, response code, and validation status. Use a structured log format (JSON) for post-migration analysis.
  6. Migrate in order. Contact properties → Contacts → Tickets → Messages. Dependencies matter. A ticket referencing a non-existent contact ID will fail.
  7. Map operators early. Ensure all human agents have logged into Tidio at least once so their operator IDs are generated before you begin mapping ticket assignees.
  8. Plan for parallel running. Keep Plain active for 1–2 weeks after Tidio goes live. Route new conversations to Tidio while agents can still reference Plain for historical context.
  9. Get your schema right before the first load. Tidio contact property types are immutable after creation. Verify your property definitions on a test project first.
  10. Handle the created_at timestamp explicitly. If you omit created_at when creating tickets via the API, Tidio stamps the current date. This destroys historical timeline accuracy and makes migrated tickets appear as if they were all created on migration day.

Automation Script Outline

Here's a high-level structure for a Node.js-based migration script:

// migration.js — Plain to Tidio Migration Script (Outline)
const { GraphQLClient } = require('graphql-request');
const axios = require('axios');
const fs = require('fs');
const Database = require('better-sqlite3');
 
// --- Config ---
const PLAIN_API = 'https://core-api.uk.plain.com/graphql/v1';
const TIDIO_API = 'https://api.tidio.co';
const BATCH_SIZE = 50;
const RATE_LIMIT_DELAY_MS = 1000; // 1 req/sec, safe margin for 60 req/min
 
// --- State DB for ID mappings and resume capability ---
const db = new Database('migration_state.db');
db.exec(`
  CREATE TABLE IF NOT EXISTS contact_map
    (plain_id TEXT PRIMARY KEY, tidio_id TEXT, email TEXT);
  CREATE TABLE IF NOT EXISTS ticket_map
    (plain_id TEXT PRIMARY KEY, tidio_id TEXT);
  CREATE TABLE IF NOT EXISTS migration_log
    (id INTEGER PRIMARY KEY AUTOINCREMENT, timestamp TEXT, entity TEXT,
     plain_id TEXT, tidio_id TEXT, status TEXT, error TEXT);
`);
 
// --- Clients ---
const plainClient = new GraphQLClient(PLAIN_API, {
  headers: { Authorization: `Bearer ${process.env.PLAIN_API_KEY}` }
});
 
const tidioClient = axios.create({
  baseURL: TIDIO_API,
  headers: {
    'Accept': 'application/json; version=1',
    'X-Tidio-Openapi-Client-Id': process.env.TIDIO_CLIENT_ID,
    'X-Tidio-Openapi-Client-Secret': process.env.TIDIO_CLIENT_SECRET
  }
});
 
// --- Timeline entry types to migrate ---
const MIGRATABLE_TYPES = new Set([
  'EmailEntry', 'ChatEntry', 'NoteEntry', 'CustomEntry'
]);
 
// --- Main Flow ---
async function migrate() {
  console.log('Step 1: Extracting customers from Plain...');
  const customers = await extractAllCustomers();
  fs.writeFileSync(`backup_customers_${Date.now()}.json`,
    JSON.stringify(customers, null, 2));
 
  console.log(`Step 2: Creating ${customers.length} contacts in Tidio...`);
  const contactMap = await loadContacts(customers);
 
  console.log('Step 3: Extracting threads from Plain...');
  const threads = await extractAllThreads();
  fs.writeFileSync(`backup_threads_${Date.now()}.json`,
    JSON.stringify(threads, null, 2));
 
  console.log(`Step 4: Creating ${threads.length} tickets in Tidio...`);
  await loadTickets(threads, contactMap);
 
  console.log('Step 5: Validation...');
  await validateCounts(customers.length, threads.length);
 
  console.log('Migration complete.');
}
 
// --- Helper: Rate-limited Tidio request with proactive throttling ---
async function tidioRequest(method, endpoint, data) {
  await sleep(RATE_LIMIT_DELAY_MS);
  for (let attempt = 0; attempt < 5; attempt++) {
    try {
      const res = await tidioClient({ method, url: endpoint, data });
      // Proactive throttling
      const remaining = parseInt(res.headers['x-ratelimit-remaining'], 10);
      if (remaining < 5) {
        console.log(`Rate limit nearly exhausted (${remaining} remaining). Pausing 3s...`);
        await sleep(3000);
      }
      return res.data;
    } catch (err) {
      if (err.response?.status === 429) {
        const retryAfter = parseInt(err.response.headers['retry-after'] || '5', 10);
        const wait = Math.max(retryAfter, 5) * (attempt + 1);
        console.log(`Rate limited (attempt ${attempt + 1}). Waiting ${wait}s...`);
        await sleep(wait * 1000);
        continue;
      }
      // Log error and continue
      db.prepare('INSERT INTO migration_log VALUES (NULL, ?, ?, ?, NULL, ?, ?)')
        .run(new Date().toISOString(), 'api_call', '', 'error', err.message);
      throw err;
    }
  }
  throw new Error(`Failed after 5 retries: ${method} ${endpoint}`);
}
 
// --- Helper: Check for existing contact to prevent duplicates ---
async function findExistingContact(email) {
  if (!email) return null;
  const result = await tidioRequest('GET', `/contacts?email=${encodeURIComponent(email)}`);
  const contacts = result?.data || [];
  return contacts.length > 0 ? contacts[0].id : null;
}
 
function sleep(ms) {
  return new Promise(resolve => setTimeout(resolve, ms));
}
 
migrate().catch(err => {
  console.error('Migration failed:', err);
  process.exit(1);
});

This is a starting point, not production-ready code. A production script needs:

  • Idempotency: Check the mapping DB before creating each record to support resume after failure
  • Progress tracking: Log percentage complete and estimated time remaining
  • Attachment pipeline: Download from Plain signed URLs, upload to stable hosting, reference in payloads
  • Markdown-to-HTML conversion: Apply to all message bodies
  • Timeline entry filtering: Only migrate EmailEntry, ChatEntry, NoteEntry, CustomEntry
  • Validation assertions: After each batch, verify counts and spot-check field values

Making the Right Call

Plain and Tidio serve different audiences. Plain is infrastructure for engineering-led B2B teams; Tidio is a turnkey platform for SMBs and e-commerce. The migration path between them requires real engineering work because there's no overlap in their API architecture (GraphQL vs. REST) and meaningful differences in their data models (hierarchical multi-tenant vs. flat contact-centric).

The practical version of this advice: move the history, preserve the context that agents actually use daily (company names, tenant associations, custom field values), flatten what Tidio can't model natively into contact properties and internal notes, and be explicit with stakeholders about what is structurally lost (company-level reporting, multi-tenant grouping, multi-assignee threads, per-thread custom field types beyond text/dropdown).

That is what keeps a Plain-to-Tidio migration useful after go-live, not just technically complete.

Frequently Asked Questions

Can I migrate conversation history from Plain to Tidio?
Yes, but only via Tidio's REST API or JSONL ticket import — not CSV. You need to extract threads and timeline messages from Plain's GraphQL API, transform them to match Tidio's ticket structure, and load them through Tidio's OpenAPI endpoints or JSONL import. The JSONL import path requires a Plus plan or higher. There is no native one-click migration path between the two platforms.
What are Tidio's API rate limits for migration?
Tidio's OpenAPI rate limits are 60 requests per minute on the Plus plan and 120 requests per minute on Premium. Free and Starter plans do not include OpenAPI access. At maximum rate, migrating 10K conversations with associated messages can take 10+ hours of sustained API time. Implement exponential backoff for 429 responses and track the X-RateLimit-Remaining header proactively.
Does Tidio support companies or tenants like Plain?
No. Tidio has no Company, Organization, or Tenant object. Plain's Company-to-Customer and Tenant-to-Customer hierarchies must be flattened during migration. The typical approach is to store company name and tenant memberships as custom Contact Properties in Tidio, which preserves the data but loses the relational grouping.
How are attachments handled when moving from Plain to Tidio?
Plain secures attachments with expiring signed URLs. Your migration must download files from Plain and either re-upload them to Tidio or stage them at stable public URLs for the JSONL import path. Tidio's documented ticket reply endpoint does not expose attachment fields, so the JSONL import is the more reliable path for attachment-heavy history.
What Tidio plan do I need for API-based migration?
Tidio's OpenAPI requires a Plus plan ($749/month) or Premium plan ($2,999/month). Standard Starter and Growth plans do not include OpenAPI access. Confirm your plan includes API access before building any migration scripts.

More from our Blog