---
title: "JSON to Tidio Migration: The Complete Technical Guide"
slug: json-to-tidio-migration-the-complete-technical-guide
date: 2026-08-17
author: Raaj
categories: [Tidio, Migration Guide, Help Desk]
excerpt: "Step-by-step guide to migrating JSON data into Tidio. Covers JSONL import, API schema mapping, contact deduplication, rate limits, and edge cases."
tldr: "Use Tidio's JSONL importer for historical tickets, load contacts separately, and run a second API pass for tags, custom fields, and enrichment."
canonical: https://clonepartner.com/blog/json-to-tidio-migration-the-complete-technical-guide/
---

# JSON to Tidio Migration: The Complete Technical Guide


# JSON to Tidio Migration: The Complete Technical Guide

> [!NOTE]
> **TL;DR: JSON → Tidio Migration**
>
> Tidio's JSONL ticket importer (Plus plan, $749/mo+) is the right path for historical data — it preserves `createdAt` timestamps and supports attachments via public URLs. The OpenAPI's `POST /tickets/as-contact` endpoint does not accept `createdAt` overrides, so every ticket created through it gets stamped with the current time. Load contacts before tickets, thread replies sequentially, and plan a second API pass for tags and custom fields — the JSONL import schema does not include those fields. For large JSON exports, use streaming parsers to avoid memory exhaustion during transformation.

If you're migrating into Tidio from a proprietary database, an internal tool, or an unsupported helpdesk, your data is probably sitting in a JSON export. Unlike platform-to-platform migrations where API mappings are known quantities, a **JSON to Tidio migration** means you're defining the schema translation from scratch.

JSON is a transport format, not a data model. Tidio enforces a strict relational model centered around Contacts and Help Desk Tickets. Every conversation thread must map to a known contact. Every message needs an author type and `htmlContent`. Your custom fields, tags, and agent identities all need to resolve to objects that already exist in your Tidio workspace.

This guide covers the full methodology: choosing the right load path, mapping your schema to Tidio's object model, handling the JSONL format requirements, managing API constraints, and avoiding the edge cases that silently break imports.

## What a JSON to Tidio Migration Actually Means

A **JSON to Tidio migration** moves structured ticket data — contacts, ticket threads, messages, tags, custom fields, and attachments — from JSON files into Tidio's Help Desk and contact management system.

The source JSON can come from anywhere:

- **Helpdesk API exports** — Zendesk, Freshdesk, Intercom, Help Scout, or any platform that returns JSON from its API
- **Database dumps** — PostgreSQL `COPY` or MongoDB `mongoexport` output in JSON/JSONL format
- **Custom application exports** — Internal ticketing tools serialized to JSON
- **Intermediate staging** — JSON produced by export scripts from platforms that don't export clean formats natively (Intercom exports conversation data to cloud storage as JSON/JSONL, while its CSV export does not include message content; Gorgias' CSV export is limited to the most recent 30 days of public messages)

The challenge isn't parsing JSON — that's trivial. The challenge is mapping your source schema to Tidio's specific object model, respecting import constraints, and handling data that Tidio's import paths don't accept.

If your JSON came from a specific platform, use a source-specific guide before writing transforms: [Intercom to Tidio](https://clonepartner.com/blog/blog/intercom-to-tidio-migration-guide/), [LiveChat to Tidio](https://clonepartner.com/blog/blog/livechat-to-tidio-migration-the-technical-guide/), [Puzzel to Tidio](https://clonepartner.com/blog/blog/puzzel-case-management-to-tidio-migration-technical-guide/), or [Freshdesk to Tidio](https://clonepartner.com/blog/blog/freshdesk-to-tidio-migration-guide/).

## Two Load Paths: JSONL Import vs. OpenAPI

The first architectural decision is *how* to get data into Tidio. There are two distinct ingestion paths, and your choice dictates the entire migration design.

### Path 1: JSONL Ticket Import (Historical Data)

Tidio's Help Desk supports a JSONL file import for external tickets, available on the Plus plan ($749/mo+). This is the correct path for historical data because it preserves original `createdAt` timestamps on both tickets and messages. ([help.tidio.com](https://help.tidio.com/hc/en-us/articles/25897882005788-Import-your-tickets-into-Tidio))

Key constraints:

- **Format:** `.jsonl` — one complete JSON object per line, not a single array-wrapped file ([jsonlines.org](https://jsonlines.org/))
- **File size:** Up to 1 GB per import
- **Validation:** Tidio pre-validates the entire file before importing anything. If 100 or more tickets are invalid, the import aborts completely and returns a validation error — nothing is loaded. If your file has fewer than 100 tickets and all are invalid, it also fails
- **Attachments:** Supported via `publicUrl` — files must be downloadable from a publicly accessible URL. Local file uploads are not supported
- **Schema limitations:** Tags and ticket custom fields are not present in the JSONL import schema. Submitting them does not produce a validation error — they are silently ignored. They require a post-import API enrichment pass
- **`htmlContent` is required** on every imported message. If your source only has plain text, you must wrap it in minimal HTML before writing the JSONL. A missing `htmlContent` field causes that ticket to count toward the invalid-record threshold

**Best for:** Large historical imports (>5,000 tickets), any case where timestamp fidelity matters.

### Path 2: Tidio OpenAPI (Live Operations)

The [Tidio OpenAPI](https://developers.tidio.com/docs/openapi-enable) provides programmatic access to create contacts, tickets, and replies. It requires the Plus plan and authenticates via `X-Tidio-Openapi-Client-Id` and `X-Tidio-Openapi-Client-Secret` headers. Every request must include `Accept: application/json; version=1`.

Key constraints:

- **No timestamp override:** `POST /tickets/as-contact` stamps every ticket with the current time. There is no `createdAt` parameter in the documented request body. If you import 50,000 historical tickets from 2022, they'll all appear as created today
- **Rate limits:** 60 requests/minute on Plus, 120 on Premium, counted per project
- **Reply ordering is call-order dependent:** Tidio does not accept timestamps on replies — they're ordered by when the API call executes. Parallel processing will scramble your threads
- **Contact creation is not an upsert:** `POST /contacts` always creates a new record, even if that email already exists in Tidio. A duplicate email returns HTTP 201, not an error

**Best for:** Ongoing syncs, smaller migrations (<5,000 tickets), or cases where original timestamps don't matter.

> [!WARNING]
> Do **not** use `POST /tickets/as-contact` or `POST /tickets/{ticketId}/reply` for historical backfills if timestamp fidelity matters. Their documented request bodies do not accept `createdAt` values, so they create data in the present, not in historical order. Use the JSONL ticket importer for old threads.

### The Standard Approach: Hybrid

For most migrations, use both paths. Transform your historical JSON into Tidio's JSONL format for the bulk ticket import (preserving timestamps). Load contacts via CSV/TXT import or API. Then run a secondary enrichment script via the OpenAPI to add tags, custom fields, and any metadata the JSONL schema doesn't support.

## Tidio's Data Model: What You're Targeting

Before writing transformation code, understand what Tidio stores and how objects relate.

| Tidio Object | Primary Path | Key Fields | Constraints |
|---|---|---|---|
| Contacts | `POST /contacts` or CSV/TXT import | `email`, `first_name`, `last_name`, `phone`, custom properties | API always creates new records — no upsert. Duplicate email returns HTTP 201. CSV import can update existing by email |
| Tickets | JSONL import or `POST /tickets/as-contact` | `contact`, `subject`, `status`, `messages`, `createdAt` | JSONL preserves timestamps; API does not |
| Messages | Part of ticket in JSONL; `POST /tickets/{ticketId}/reply` via API | `author.type`, `author.email`, `htmlContent`, `createdAt` | `htmlContent` required in JSONL. API reply ordering depends on call order |
| Tags | Create in admin panel; apply via `PATCH /tickets/{ticketId}` | `tag_ids` (integer array) | Not in JSONL schema — silently ignored if submitted. Must pre-create in Settings > Tags |
| Custom Fields | Create in admin panel; set via `PATCH /tickets/{ticketId}` | `custom_fields` (id/value pairs) | Not in JSONL schema — silently ignored if submitted. Must pre-create. Up to 100 fields per PATCH request |
| Departments | Create in admin panel; read via `GET /departments` | `departmentName` in JSONL, `assigned_department_id` in API | Must exist before import. Unmatched `departmentName` assigns ticket to "General" |
| Operators | Invite via panel; read via `GET /operators` | `operatorEmail` in JSONL | Cannot create via API. Emails must match exactly. Unmatched email leaves ticket unassigned |

> [!WARNING]
> **Email is the join key everywhere.** Tidio links tickets to contacts via email. If your source uses internal user IDs, you must resolve them to email addresses before loading anything.

## JSONL Schema: Valid Format and Field Mapping

Below is a **complete, valid JSONL line** — a single ticket object with two messages and one attachment. This is the minimum structure Tidio's importer accepts when all optional fields are populated:

```json
{"contact":{"email":"jane.doe@example.com","name":"Jane Doe"},"status":"solved","subject":"Refund request #10482","createdAt":"2023-06-15T09:22:00Z","operatorEmail":"agent@yourcompany.com","departmentName":"Billing","priority":"high","messages":[{"author":{"type":"contact","email":"jane.doe@example.com"},"htmlContent":"<p>I'd like a refund for order #10482. It arrived damaged.</p>","plainTextContent":"I'd like a refund for order #10482. It arrived damaged.","createdAt":"2023-06-15T09:22:00Z","type":"public","recipients":{"to":"support@yourcompany.com"},"attachments":[{"publicUrl":"https://cdn.yourcompany.com/uploads/damaged-item.jpg","filename":"damaged-item.jpg"}]},{"author":{"type":"operator","email":"agent@yourcompany.com"},"htmlContent":"<p>Hi Jane, I've processed your refund. Allow 3–5 business days.</p>","plainTextContent":"Hi Jane, I've processed your refund. Allow 3–5 business days.","createdAt":"2023-06-15T11:04:00Z","type":"public","recipients":{"to":"jane.doe@example.com"}}]}
```

In a real `.jsonl` file, each ticket is one line. No commas between lines. No wrapping array.

### Field Reference

**Root ticket object:**

| Field | Type | Required | Accepted Values / Notes |
|---|---|---|---|
| `contact.email` | string | Yes | Must be a valid email format |
| `contact.name` | string | No | Display name for the contact |
| `status` | string | Yes | `open`, `solved`, `closed`, `pending`, `on_hold` |
| `subject` | string | Yes | Plain text subject line |
| `createdAt` | ISO 8601 string | No | Omit to default to import time |
| `operatorEmail` | string | No | Must match an active Tidio operator exactly. Unmatched → unassigned |
| `departmentName` | string | No | Must match an existing department name exactly. Unmatched → "General" |
| `priority` | string | No | `low`, `normal`, `high`, `urgent` |
| `mailbox` | string | No | Must match a configured Tidio mailbox name. If unmatched or omitted, uses the default mailbox |
| `messages` | array | Yes | At least one message required |

**Message object (within `messages []`):**

| Field | Type | Required | Accepted Values / Notes |
|---|---|---|---|
| `author.type` | string | Yes | `operator` or `contact` |
| `author.email` | string | Yes | Must resolve to a Tidio operator (if type is `operator`) or any valid email |
| `htmlContent` | string | Yes | Must be valid HTML. Missing this field marks the ticket invalid |
| `plainTextContent` | string | No | Plain text version; used in email clients that don't render HTML |
| `createdAt` | ISO 8601 string | No | Omit to default to import time |
| `type` | string | No | `public` (default) or `internal` (internal note, not visible to contact) |
| `recipients.to` | string | No | Email address the message was sent to |
| `attachments [].publicUrl` | string | No | Must be a publicly accessible, downloadable URL |
| `attachments [].filename` | string | No | Display filename for the attachment |

### Validation Error Behavior

When you upload a JSONL file that fails validation, Tidio returns a validation error report before importing anything. The report identifies which line numbers failed and why. Common validation failure messages:

- `"htmlContent is required"` — Message object is missing the `htmlContent` field
- `"contact.email is invalid"` — Email address is malformed or missing
- `"status is not valid"` — Status value is not in the accepted enum
- `"operator not found"` — `operatorEmail` does not match any active operator

If 100 or more tickets are invalid, the import aborts entirely — zero records are loaded. If fewer than 100 tickets are invalid in a file with mixed valid/invalid records, Tidio imports the valid records and skips the invalid ones, reporting which lines were skipped.

### Source Field Mapping

- **Customer / requester → `contact`** — Requires `email` at minimum. If your source lacks emails, generate a placeholder (e.g., `anon-{source_id}@import.local`)
- **Status → `status`** — Valid values are `open`, `solved`, `closed`, `pending`, `on_hold`. Map your legacy statuses to the closest equivalent. If your source has custom statuses like `waiting_on_dev`, map to `pending` and preserve the original status in a tag or custom field
- **Assignee → `operatorEmail`** — Must match an active Tidio operator. For departed agents, create a suspended profile or map to a generic `legacy-agent@yourcompany.com` account
- **Department / queue → `departmentName`** — Must exist in Tidio before import. Unmatched names route to "General"
- **Labels / metadata → tags and custom fields** — Not in the JSONL schema. Handle in the post-import enrichment pass
- **Attachments → `attachments [].publicUrl`** — Must be downloadable from a public URL. Mirror private S3 keys or expiring signed URLs before running the import

> [!TIP]
> Put **customer-level** attributes (plan, account tier, CRM ID) on **contact properties**. Put **case-level** values (refund reason, escalation cause, bug severity) on **ticket custom fields**. Tidio's custom fields apply to individual tickets and don't appear in the contacts list. ([help.tidio.com](https://help.tidio.com/hc/en-us/articles/25942550979228-Custom-fields-for-tickets))

### Contact Property Types

Tidio contact properties are typed scalars. The API rejects values of the wrong type or properties that don't exist in your workspace. Allowed property types:

| Type | Description | Example value |
|---|---|---|
| `text` | Freeform string | `"Enterprise"` |
| `email` | Email address format | `"billing@example.com"` |
| `number` | Numeric (integer or decimal) | `42` |
| `phone` | Phone number string | `"+14155552671"` |
| `url` | URL string | `"https://example.com"` |

Arrays, nested objects, and vendor-specific blobs must be flattened into one of these scalar types or serialized as a `text` field before import. Properties must be created in the admin panel before you attempt to set values via the API.

The `distinct_id` field accepts up to 55 characters and is useful for storing source system record IDs. However, Tidio does not enforce uniqueness on `distinct_id`, and there is no API endpoint to query contacts by `distinct_id`. It functions as a reference label, not a lookup key — use it to record the source system ID for your own mapping purposes, but build your deduplication logic on email instead.

## Step-by-Step: Executing the Migration

### Step 1: Audit Your Source JSON

Before mapping anything, classify what your JSON actually contains:

- Ticket-level records with message arrays, or flat chat transcripts?
- Customer profiles with nested metadata, or separate contact records?
- Attachments as URLs, base64 blobs, or opaque IDs?
- Agent identities by email, display name, or internal ID?
- Statuses as strings, integers, or enums?

This decides whether you can transform directly into Tidio's JSONL or need an intermediate normalization step. If your source JSON comes from a chat-first system (like Intercom or LiveChat), note the model mismatch: Tidio's bulk import path is for **external tickets**, not historical live-chat sessions. Old chat transcripts land as Help Desk tickets.

### Step 2: Prepare Tidio Infrastructure

Before importing a single record, create the destination objects your data will reference:

- **Operators** — Invite all agents who need to appear as message authors. Retrieve their IDs and emails via `GET /operators`
- **Departments** — Create in Settings > Team > Departments. Retrieve IDs via `GET /departments`
- **Tags** — Create in Settings > Tags. Retrieve IDs via `GET /tickets/tags` to build your mapping table
- **Custom Fields** — Create in Settings > Custom Fields. Retrieve definitions via `GET /tickets/custom-fields`
- **Contact Properties** — Define property types in the admin panel before setting values via the API
- **Mailboxes** — Confirm mailbox names match what you'll use in `mailbox` fields in the JSONL
- **API Credentials** — Generate in Developer > OpenAPI as a project owner or admin. Include `Accept: application/json; version=1` on every request

This is where most DIY migrations cut corners. If your transform emits `departmentName: Billing` but Billing doesn't exist in Tidio, the ticket routes to General silently. If imported operator emails don't match real Tidio operators, messages go unassigned.

### Step 3: Transform Source JSON to Tidio JSONL

Build a deterministic transform that emits one ticket per line. Here's a Python example:

```python
import json
from html import escape

def as_html(text):
    """Wrap plain text in minimal HTML — htmlContent is required."""
    return '<p>{}</p>'.format(escape(text or ''))

STATUS_MAP = {
    'open': 'open',
    'pending': 'pending',
    'waiting': 'pending',
    'resolved': 'solved',
    'closed': 'closed',
    'on_hold': 'on_hold',
    # Add your source-specific statuses here
}

def to_tidio_ticket(row):
    messages = []
    for m in sorted(row['messages'], key=lambda x: x['created_at']):
        messages.append({
            'author': {
                'type': 'operator' if m['author_role'] == 'agent' else 'contact',
                'email': m['author_email'],
            },
            'htmlContent': m.get('html') or as_html(m.get('text')),
            'plainTextContent': m.get('text'),
            'createdAt': m['created_at'],
            'type': 'internal' if m.get('is_internal') else 'public',
            'recipients': {
                'to': m.get('to') or row['contact_email'],
            },
            'attachments': [
                {
                    'publicUrl': a['url'],
                    'filename': a.get('filename'),
                }
                for a in m.get('attachments', [])
            ],
        })

    return {
        'contact': {
            'email': row['contact_email'],
            'name': row.get('contact_name'),
        },
        'status': STATUS_MAP.get(row.get('status', 'open'), 'open'),
        'subject': row['subject'],
        'createdAt': row.get('ticket_created_at'),
        'operatorEmail': row.get('assignee_email'),
        'departmentName': row.get('department_name'),
        'priority': row.get('priority', 'normal'),
        'messages': messages,
    }

with open('source.json', 'r', encoding='utf-8') as f:
    rows = json.load(f)

with open('tidio-import.jsonl', 'w', encoding='utf-8') as out:
    for row in rows:
        out.write(json.dumps(to_tidio_ticket(row), ensure_ascii=False))
        out.write('\n')
```

#### Streaming Large JSON Exports

If your source JSON exceeds a few hundred megabytes, don't load it all into memory. A 5 GB export will crash `json.load()` in Python or `JSON.parse()` in Node.js with an out-of-memory error.

Use a streaming parser. In Node.js with `JSONStream`:

```javascript
const fs = require('fs');
const JSONStream = require('JSONStream');
const es = require('event-stream');

const readStream = fs.createReadStream('./legacy_export.json', { encoding: 'utf8' });
const writeStream = fs.createWriteStream('./tidio_import.jsonl');

readStream
  .pipe(JSONStream.parse('*'))
  .pipe(es.mapSync(function (record) {
    const tidioTicket = transformToTidioSchema(record);
    writeStream.write(JSON.stringify(tidioTicket) + '\n');
  }))
  .on('error', (err) => console.error('Stream error:', err))
  .on('end', () => {
    console.log('Transformation complete.');
    writeStream.end();
  });
```

In Python, use `ijson` for the same purpose. This keeps memory usage flat regardless of file size.

### Step 4: Import Contacts

Contacts must exist in Tidio before tickets reference them. You have two options:

**Option A: CSV/TXT Import (simpler for one-time loads)**

Tidio's UI contact importer accepts CSV or TXT files, expects UTF-8, lets you map columns interactively, and — critically — lets you choose to **update existing contacts** or **skip existing contacts** by email. This gives you built-in deduplication that the API lacks. ([help.tidio.com](https://help.tidio.com/hc/en-us/articles/5463416657052-Import-Contacts-Into-Tidio))

**Option B: OpenAPI (for scripted or repeatable loads)**

```python
import requests
import time

TIDIO_BASE = "https://api.tidio.com"
HEADERS = {
    "X-Tidio-Openapi-Client-Id": "YOUR_CLIENT_ID",
    "X-Tidio-Openapi-Client-Secret": "YOUR_CLIENT_SECRET",
    "Content-Type": "application/json",
    "Accept": "application/json; version=1"
}

def create_contact(contact):
    payload = {
        "email": contact["email"],
        "first_name": contact.get("first_name", ""),
        "last_name": contact.get("last_name", ""),
        "phone": contact.get("phone", "")
    }
    resp = requests.post(f"{TIDIO_BASE}/contacts", json=payload, headers=HEADERS)
    if resp.status_code == 201:
        return resp.json()
    else:
        log_error(contact["email"], resp.status_code, resp.text)
        return None

# Respect rate limits: 60 req/min on Plus
for contact in deduplicated_contacts:
    create_contact(contact)
    time.sleep(1.1)  # ~54 req/min, safe buffer under 60 limit
```

> [!WARNING]
> **Deduplication trap:** `POST /contacts` always creates a new record and returns HTTP 201 — it does not deduplicate by email and does not return an error for duplicates. If you run the import twice, you'll get double the contacts. Deduplicate in your source data first, and build checkpoint logic so you can resume a failed run without creating duplicates. Batch endpoints cap at 100 records with all-or-nothing validation.

### Step 5: Import Tickets via JSONL

Start with a **2–3 ticket** pilot file before processing your full dataset. Test with the minimum required fields first (`contact.email`, `status`, `subject`, one message with `htmlContent`), then add optional fields. This isolates which fields cause validation failures.

The importer validates the entire file before loading anything. If your pilot file fails, the validation error report identifies which line failed and why. Fix the issue and re-upload — partial imports do not occur. ([help.tidio.com](https://help.tidio.com/hc/en-us/articles/25897882005788-Import-your-tickets-into-Tidio))

For files over 1 GB, split into chunks before uploading. There is no file-splitting or multi-part upload within the importer.

### Step 6: Post-Import Enrichment via API

The JSONL import covers tickets, messages, contacts, timestamps, departments, operator assignment, priority, and mailbox routing. Everything else — tags, custom fields, status corrections — requires a separate API pass.

The enrichment workflow:

1. **Retrieve imported tickets** via `GET /tickets` with pagination. The endpoint accepts `page` and `per_page` parameters (maximum 100 records per page). Match to your source records by contact email and subject, or by preserved `createdAt` timestamp
2. **Apply tags** via `PATCH /tickets/{ticketId}` with the `tag_ids` integer array
3. **Set custom fields** via `PATCH /tickets/{ticketId}` with `custom_fields` (up to 100 fields per request)
4. **Update status** if the JSONL import didn't set it correctly
5. **Reassign operators** if needed post-load

Because the JSONL import generates new Tidio ticket IDs, maintain a mapping table (`source_ticket_id → tidio_ticket_id`) throughout this process. Build it during Step 6 by querying `GET /tickets` and matching on preserved timestamps and contact email.

When running enrichment across thousands of records, implement exponential backoff for HTTP 429 responses. Check the `x-ratelimit-remaining` response header proactively rather than waiting to hit the wall:

```javascript
async function fetchWithBackoff(url, options, retries = 5, delay = 1000) {
  const response = await fetch(url, options);
  
  if (response.status === 429) {
    if (retries === 0) throw new Error('Max retries reached');
    console.warn(`Rate limited. Retrying in ${delay}ms...`);
    await new Promise(res => setTimeout(res, delay));
    return fetchWithBackoff(url, options, retries - 1, delay * 2);
  }
  
  if (!response.ok) throw new Error(`HTTP ${response.status}`);
  return response.json();
}
```

### Step 7: Validate

Run these checks before declaring success:

- [ ] **Contact count** — Unique contacts in source vs. Tidio (`GET /contacts`, paginate at 100 records/page)
- [ ] **Ticket count** — Total tickets in source vs. Tidio (`GET /tickets`, paginate at 100 records/page)
- [ ] **Message count** — Spot-check 20–50 tickets for correct message counts
- [ ] **Message ordering** — Verify threads read chronologically in Tidio's inbox
- [ ] **Timestamp accuracy** — Confirm `createdAt` dates match the originals on a representative sample
- [ ] **Tag and custom field coverage** — Verify the enrichment pass completed; check for silently-skipped tags caused by missing tag IDs in the mapping table
- [ ] **Attachment accessibility** — Click through attachment links in sample tickets
- [ ] **Operator attribution** — Confirm agent replies show the correct operator, not an unassigned fallback
- [ ] **No duplicate contacts** — Search for known emails; each should appear exactly once
- [ ] **Department routing** — Verify tickets landed in the correct departments, not silently routed to "General"
- [ ] **Status accuracy** — Verify closed/solved tickets are not showing as open due to unmapped status values

Use API reads for validation — don't rely on Tidio webhooks during migration. OpenAPI-created events do not fire webhooks, delivery order isn't guaranteed, and duplicates are possible.

## Troubleshooting: Symptom → Cause → Fix

| Symptom | Likely Cause | Fix |
|---|---|---|
| Import aborts, zero records loaded | File contains 100+ invalid tickets, or all tickets in a small file are invalid | Download the validation report, fix the identified line numbers, re-upload |
| Import aborts, zero records loaded | File exceeds 1 GB | Split into multiple files under 1 GB and import sequentially |
| Individual tickets skipped, rest imported | Fewer than 100 invalid records in the file | Check validation report for skipped line numbers; fix and re-import those records separately |
| `"htmlContent is required"` error | Message object missing `htmlContent` field | Wrap all plain text in `<p>` tags before writing JSONL |
| `"status is not valid"` error | Status value not in accepted enum | Valid values: `open`, `solved`, `closed`, `pending`, `on_hold` |
| `"operator not found"` error | `operatorEmail` doesn't match any active Tidio operator | Verify operator emails via `GET /operators`; create suspended profiles for departed agents |
| Tickets appear with today's date, not original dates | Used `POST /tickets/as-contact` instead of JSONL import | Use the JSONL ticket importer for historical data |
| Duplicate contacts after re-run | `POST /contacts` always creates new records | Implement checkpoint logic; use CSV import with "skip existing" option for initial load |
| Tags missing after import | Tags not in JSONL schema — silently ignored | Run enrichment pass via `PATCH /tickets/{ticketId}` with `tag_ids` |
| Custom fields missing after import | Custom fields not in JSONL schema — silently ignored | Run enrichment pass via `PATCH /tickets/{ticketId}` with `custom_fields` |
| Messages in wrong order in thread | API replies created in parallel | Create replies sequentially in strict chronological order |
| Attachments not loading | Source URLs are private, expired, or require authentication | Mirror files to publicly accessible storage before import |
| Tickets routed to wrong department | `departmentName` value doesn't match any existing department | Verify department names via `GET /departments`; unmatched names route to "General" silently |
| Contact property rejected by API | Property doesn't exist in workspace or value is wrong type | Create all properties in admin panel first; validate types against the allowed type list |

## Rate Limit Math: How Long Will This Take?

Rate limits are 60 req/min on Plus and 120 on Premium, counted per project.

For an **API-only migration** of 10,000 tickets with an average of 4 messages each:

| Operation | API Calls | At 60 req/min | At 120 req/min |
|---|---|---|---|
| Create contacts (deduplicated) | ~5,000 | ~83 min | ~42 min |
| Create tickets | 10,000 | ~167 min | ~83 min |
| Add replies (3 per ticket avg) | 30,000 | ~500 min | ~250 min |
| Enrichment (status, tags) | 10,000 | ~167 min | ~83 min |
| **Total** | **~55,000** | **~15 hours** | **~7.5 hours** |

That's clean runtime with no errors or retries. Real-world migrations add 20–30% overhead for error handling, rate limit backoff, and validation passes.

The **JSONL import path eliminates the ticket and reply API calls entirely** — you only need the API for contacts (if not using CSV import) and the enrichment pass. That can reduce API time by 70%+ on a 10,000-ticket migration. For datasets over 20,000 tickets, the API-only approach becomes impractical — at 60 req/min, 80,000 API calls takes over 22 hours of clean runtime before accounting for errors.

## Common Source Schemas and How to Map Them

### Zendesk JSON Export

Zendesk's ticket export produces nested JSON with `tickets [].comments []` containing both public replies and internal notes. Map each comment's `public` field to the message `type` field in Tidio's JSONL schema (`public: true` → `type: "public"`, `public: false` → `type: "internal"`). Resolve Zendesk's `author_id` to email addresses via the Zendesk Users API (`GET /api/v2/users/{id}.json`) before building your JSONL. Zendesk's `status` values map as follows: `new` → `open`, `open` → `open`, `pending` → `pending`, `hold` → `on_hold`, `solved` → `solved`, `closed` → `closed`.

### Freshdesk API Export

Freshdesk tickets use numeric `requester_id` rather than email on the ticket object. Resolve each `requester_id` to an email via the Freshdesk contacts API (`GET /api/v2/contacts/{id}`) before building your JSONL. Freshdesk `status` integers map as: `2` (Open) → `open`, `3` (Pending) → `pending`, `4` (Resolved) → `solved`, `5` (Closed) → `closed`. See our [Freshdesk to Tidio migration guide](https://clonepartner.com/blog/blog/freshdesk-to-tidio-migration-guide/) for the full mapping.

### Intercom JSON Export

Intercom conversations use an event-stream model with `conversation_parts`. Each part can be a message, note, assignment, or state change — only parts with `part_type: "comment"` should map to Tidio messages. Parts with `part_type: "assignment"` or `part_type: "open"` are metadata events, not messages, and should be discarded. Intercom exports to JSON/JSONL via cloud storage — its CSV export does not include conversation content. See our [Intercom to Tidio migration guide](https://clonepartner.com/blog/blog/intercom-to-tidio-migration-guide/).

### Custom / Internal Tools

If you're exporting from a bespoke system, define a canonical intermediate schema first, then transform that into Tidio's JSONL. This decouples source-specific parsing from Tidio-specific formatting and makes each transform stage independently testable.

## Edge Cases and Failure Modes

**Contacts without email addresses.** Tidio requires email to link tickets to contacts. If your source has anonymous visitors or phone-only contacts, generate a placeholder email (e.g., `anon-{source_id}@import.local`) or exclude those tickets.

**Missing `htmlContent`.** The JSONL import requires `htmlContent` on every message. A missing field marks that ticket as invalid and counts toward the 100-ticket abort threshold. Wrap all plain text in `<p>` tags before writing JSONL.

**Attachments on private infrastructure.** If your JSON references files on private S3 buckets or behind expiring signed URLs, those URLs won't work for Tidio's JSONL import. Mirror files to a publicly accessible location and ensure URLs remain valid for the full import window — Tidio fetches attachments asynchronously after you upload the JSONL file.

**Duplicate contacts on re-runs.** `POST /contacts` always creates new records and returns HTTP 201 regardless of whether the email already exists. If your script fails mid-run and you restart without checkpoint logic, every previously-created contact gets duplicated. Once tickets are attached to wrong contact records, cleanup is manual.

**Character encoding mismatches.** JSON files from different platforms may encode special characters, emoji, or non-Latin scripts differently. Ensure all source files are UTF-8. Tidio's API expects UTF-8.

**Operator email mismatches.** If a message's `operatorEmail` doesn't match an active Tidio operator, the ticket is imported unassigned — it does not fail validation, it silently drops the assignment. Create suspended profiles for departed agents, or map their messages to a generic `legacy-agent@yourcompany.com` account.

**Tag name vs. ID mismatch.** Your source has tag names; Tidio's enrichment API requires integer tag IDs. If a source tag doesn't match any pre-created Tidio tag, the enrichment `PATCH` call will silently skip it. Build your tag name → tag ID mapping table before running enrichment, and handle unmapped tags explicitly (log them, create them, or discard intentionally).

**Nested source data.** Tidio contact properties are typed scalar values. Arrays, nested objects, or vendor-specific blobs need to be flattened into scalar properties or serialized into a text field before import.

**Message length.** Legacy systems sometimes allow extremely long internal notes or error logs in ticket bodies. If a message exceeds Tidio's character limits, truncate programmatically and append a note indicating the original payload was shortened, or split into multiple sequential messages.

**Unmapped status values.** If your source status doesn't appear in Tidio's accepted enum (`open`, `solved`, `closed`, `pending`, `on_hold`), the ticket fails JSONL validation. Every source status must map to one of these five values before the file is written.

## What Doesn't Survive the Migration

Be explicit with stakeholders about what gets lost:

- **Original timestamps** — Only preserved through the JSONL import, not the API
- **Ticket status history / audit trails** — Only the final status transfers; intermediate state transitions don't
- **Conversation assignment history** — Current assignment can be set; the log of who-handled-what-when is lost
- **Chatbot and automation history** — Flow triggers, bot interactions, and automation logs from the source platform don't transfer
- **Webhook-dependent workflows** — OpenAPI-created events don't fire Tidio webhooks, so downstream automation won't activate during import

None of these are dealbreakers for most teams, but set expectations before migration day.

## Plan Requirements and Cost

Backend API access requires the Plus plan ($749/mo) or above. Free through Growth tiers ($59/mo) only expose a products endpoint — no ticket or contact APIs. Tidio's plan stack jumps from Growth directly to Plus, with no mid-tier option.

Some teams upgrade to Plus for the migration period, run the import, then evaluate whether to stay or adjust. Be aware that downgrading removes API access, so any ongoing sync or enrichment scripts stop working immediately.

## When This Gets Complex Enough to Outsource

Signs that the migration warrants dedicated engineering effort:

- Dataset exceeds 20,000 tickets — API-only approaches exceed 22 hours of clean runtime at Plus-tier rate limits
- Timestamp preservation is required — JSONL format must be used correctly, and the enrichment pass must be sequenced properly
- Source JSON is inconsistent — missing emails, mixed encodings, schema variation across records, or attachments on expiring private URLs
- Multiple source systems — migrating from Zendesk AND an internal tool simultaneously compounds schema translation complexity
- Zero tolerance for downtime — cutover sequencing and rollback planning require operational discipline that takes time to build correctly

A JSON-to-Tidio migration is buildable in-house when the JSON is clean, attachments are publicly accessible, and the dataset is under 10,000 tickets with a consistent schema. The transform script is the easy part — it's the error handling, edge cases, validation, and cutover sequencing that consume time.

> Planning a JSON-to-Tidio migration with historical tickets, attachments, or custom fields? Our engineering team maps the source data, builds the transform, runs the import, and validates every record. Book a 30-minute call and we'll scope it together.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Can Tidio import raw JSON files directly?

Not as a generic bulk import. Historical tickets must be reshaped into Tidio's JSONL format (one JSON object per line, .jsonl extension). Contacts go through CSV/TXT import or OpenAPI. The JSONL importer requires a Plus plan and validates the entire file before importing.

### Does Tidio preserve original ticket timestamps during JSON import?

Only through the JSONL file import. The POST /tickets/as-contact API endpoint stamps tickets with the current time and has no createdAt override parameter. If timestamp fidelity matters, use the JSONL importer, which accepts createdAt on both tickets and messages.

### Can I import tags and custom fields in the Tidio JSONL file?

Tags and ticket custom fields are not documented in the JSONL import schema. Plan a post-import enrichment pass using the OpenAPI's PATCH /tickets/{ticketId} endpoint to apply tag_ids and custom_fields after tickets are loaded.

### How do I avoid duplicate contacts when importing into Tidio?

Tidio's POST /contacts endpoint always creates new records — it will not deduplicate by email. Either use the CSV/TXT UI import with the update-existing-contacts option, or deduplicate in your source data and build checkpoint logic in your import script.

### How long does a JSON to Tidio migration take via API?

At the Plus plan rate limit of 60 requests per minute, a migration of 10,000 tickets with 4 messages each requires roughly 55,000 API calls — about 15 hours of clean runtime plus 20-30% overhead. The JSONL import path eliminates ticket and reply API calls, reducing API time by 70%+.
