JSON to Tidio Migration: The Complete Technical Guide
Step-by-step guide to migrating JSON data into Tidio. Covers JSONL import, API schema mapping, contact deduplication, rate limits, and edge cases.
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
JSON to Tidio Migration: The Complete Technical Guide
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
COPYor MongoDBmongoexportoutput 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, LiveChat to Tidio, Puzzel to Tidio, or Freshdesk to Tidio.
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)
Key constraints:
- Format:
.jsonl— one complete JSON object per line, not a single array-wrapped file (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
htmlContentis required on every imported message. If your source only has plain text, you must wrap it in minimal HTML before writing the JSONL. A missinghtmlContentfield 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 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-contactstamps every ticket with the current time. There is nocreatedAtparameter 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 /contactsalways 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.
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 |
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:
{"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 thehtmlContentfield"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"—operatorEmaildoes 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— Requiresemailat minimum. If your source lacks emails, generate a placeholder (e.g.,anon-{source_id}@import.local) - Status →
status— Valid values areopen,solved,closed,pending,on_hold. Map your legacy statuses to the closest equivalent. If your source has custom statuses likewaiting_on_dev, map topendingand 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 genericlegacy-agent@yourcompany.comaccount - 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
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)
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/tagsto 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
mailboxfields in the JSONL - API Credentials — Generate in Developer > OpenAPI as a project owner or admin. Include
Accept: application/json; version=1on 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:
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:
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)
Option B: OpenAPI (for scripted or repeatable loads)
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 limitDeduplication 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)
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:
- Retrieve imported tickets via
GET /ticketswith pagination. The endpoint acceptspageandper_pageparameters (maximum 100 records per page). Match to your source records by contact email and subject, or by preservedcreatedAttimestamp - Apply tags via
PATCH /tickets/{ticketId}with thetag_idsinteger array - Set custom fields via
PATCH /tickets/{ticketId}withcustom_fields(up to 100 fields per request) - Update status if the JSONL import didn't set it correctly
- 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:
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
createdAtdates 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 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.
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.
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%+.