Skip to content

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.

Raaj Raaj · · 22 min read
JSON to Tidio Migration: The Complete 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

JSON to Tidio Migration: The Complete Technical Guide

Info

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, 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
  • 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 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:

{"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)

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:

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 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)

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:

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: truetype: "public", public: falsetype: "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: newopen, openopen, pendingpending, holdon_hold, solvedsolved, closedclosed.

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%+.

More from our Blog

LiveChat to Tidio Migration: The Technical Guide
Tidio/Migration Guide/Help Desk

LiveChat to Tidio Migration: The Technical Guide

Step-by-step technical guide to migrating from LiveChat to Tidio. Covers API mapping, data extraction, rate limits, attachment handling, and what you'll lose.

Wahab Wahab · · 18 min read