I now have everything I need. The research confirms all core technical claims with important updates — the API has moved to a credit-based model (not the simple "15,000/25,000 calls per day" stated in the original), and the API is now at v8 (not v6 as referenced throughout). Let me now write the fully optimized, fact-checked article.
Migrating away from Zoho CRM is not a simple export-and-import exercise. It is a multi-phase data engineering project — and the organizations that treat it like one come out the other side with clean, complete data. The ones that don't discover their mistakes weeks later, when a customer calls about a missing contract or an audit surfaces a corrupted activity timeline.
This guide covers every major failure point in a Zoho CRM migration: attachments, module relationships, timestamp integrity, custom field mapping, and role permissions — with working code, validated sequences, and the specific API details you need to do it right.
Before diving into solutions, here is the full landscape of what can go wrong and why:
| Challenge | Root Cause | Impact |
|---|---|---|
| Broken module relationships | Separate export files per module | Orphaned records in target system |
| Missing attachments | Excluded from standard data export | Permanent data loss |
| Timestamp corruption | Backdating handling differences between platforms | Audit trail failures |
| Custom field mapping gaps | Schema differences between platforms | Lost data structure |
| Role/permission mismatches | Different permission models | Security gaps post-migration |
Zoho CRM's built-in Settings → Data Administration → Export deliberately excludes file attachments. There is no checkbox to include them, no warning that they are missing, and no indication in the export confirmation that anything was left behind. Most teams discover this after the export is complete — sometimes after the Zoho subscription has already been cancelled.
This is not a bug. Attachments are treated as separate binary resources with their own API endpoints, entirely distinct from the structured record data that CSV export handles. Understanding this distinction is the foundation of a successful migration.
⚠️ Critical: Pull all attachments via API before cancelling your Zoho subscription. Once the org is deactivated, access to attachment data may be lost permanently.
How it works:
Use the /crm/v8/{module}/{record_id}/Attachments endpoint to programmatically fetch every attachment per record. Note that the current production API is v8 — while v6 endpoints remain functional, all new development should target v8.
Important API limits to plan around (2025):
Zoho CRM has moved from a simple "calls per day" model to a credit-based system with concurrency limits. Each attachment download consumes 1 API credit. Your daily credit budget depends on your plan:
| Edition | Daily Credit Formula | Practical Maximum |
|---|---|---|
| Free | 5,000 credits flat | 5,000 |
| Standard/Starter | 50,000 + (users × 250) | 100,000 |
| Professional | 50,000 + (users × 500) | 3,000,000 |
| Enterprise / Zoho One | 50,000 + (users × 1,000) | 5,000,000 |
| Ultimate / CRM Plus | 50,000 + (users × 2,000) | Effectively unlimited |
Concurrency limits (simultaneous requests):
| Edition | Max Concurrent Calls |
|---|---|
| Free | 5 |
| Standard/Starter | 10 |
| Professional | 15 |
| Enterprise / Zoho One | 20 |
| Ultimate / CRM Plus | 25 |
Implementation:
# Step 1: Authenticate via OAuth 2.0
import requests
import os
import time
def get_access_token(client_id, client_secret, refresh_token):
url = "https://accounts.zoho.com/oauth/v2/token"
params = {
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
"grant_type": "refresh_token"
}
response = requests.post(url, params=params)
return response.json()["access_token"]
# Step 2: Fetch all record IDs for a module (with rate-limit-aware pagination)
def get_all_record_ids(module, access_token):
record_ids = []
page = 1
while True:
url = f"https://www.zohoapis.com/crm/v8/{module}"
headers = {"Authorization": f"Zoho-oauthtoken {access_token}"}
params = {"page": page, "per_page": 200, "fields": "id"}
response = requests.get(url, headers=headers, params=params)
# Check remaining credits — header appears once you've used >50% of base credits
remaining = response.headers.get("X-API-CREDITS-REMAINING")
if remaining and int(remaining) < 1000:
print(f"⚠️ Low API credits remaining: {remaining}. Pausing.")
time.sleep(60)
data = response.json()
if "data" not in data:
break
record_ids.extend([r["id"] for r in data["data"]])
if not data.get("info", {}).get("more_records"):
break
page += 1
time.sleep(0.2) # Respect undocumented per-minute thresholds
return record_ids
# Step 3: Download attachments per record with retry logic
def download_attachments(module, record_id, access_token, output_dir, max_retries=3):
url = f"https://www.zohoapis.com/crm/v8/{module}/{record_id}/Attachments"
headers = {"Authorization": f"Zoho-oauthtoken {access_token}"}
response = requests.get(url, headers=headers).json()
if "data" not in response:
return []
manifest = []
for attachment in response["data"]:
att_id = attachment["id"]
file_name = attachment["File_Name"]
parent_id = attachment["Parent_Id"]["id"]
# IMPORTANT: Only file-type attachments can be downloaded.
# URL-type attachments will return an error — log them separately.
if attachment.get("$type") == "url":
manifest.append({
"module": module,
"record_id": parent_id,
"attachment_id": att_id,
"file_name": file_name,
"type": "url_link",
"url_value": attachment.get("$url"),
"local_path": None,
"status": "SKIPPED_URL_TYPE"
})
continue
download_url = f"https://www.zohoapis.com/crm/v8/{module}/{record_id}/Attachments/{att_id}"
for attempt in range(max_retries):
file_response = requests.get(download_url, headers=headers)
if file_response.status_code == 200:
break
elif file_response.status_code == 429:
wait = (attempt + 1) * 10
print(f"Rate limited. Waiting {wait}s before retry {attempt + 1}...")
time.sleep(wait)
else:
print(f"Error {file_response.status_code} for attachment {att_id}")
break
save_path = os.path.join(output_dir, module, str(record_id))
os.makedirs(save_path, exist_ok=True)
full_path = os.path.join(save_path, file_name)
with open(full_path, "wb") as f:
f.write(file_response.content)
manifest.append({
"module": module,
"record_id": parent_id,
"attachment_id": att_id,
"file_name": file_name,
"type": "file",
"local_path": full_path,
"status": "DOWNLOADED"
})
time.sleep(0.1) # Spacing between individual file downloads
return manifest
Critical Output: Always generate a manifest.csv mapping record_id → file_name → local_path → status. This is your re-linking key in the target system — and your audit trail if anything goes wrong.
Key limitations to plan around:
Reference: https://www.zoho.com/crm/developer/docs/api/v8/download-attachments.html
If attachments were stored via the Zoho WorkDrive integration rather than direct upload, they exist in WorkDrive's file system and require a separate API call:
# WorkDrive API — download by file ID
GET https://workdrive.zoho.com/api/v1/files/{file_id}/download
Reference: https://workdrive.zoho.com/apidocs/v1
If your team does not have developer resources, these tools handle attachment extraction without custom code:
| Tool | Attachment Support | Relationship Preservation | Cost |
|---|---|---|---|
| Trujay | ✅ Yes | ✅ Yes | Per-record pricing |
| Migrate.io | ✅ Yes | ✅ Yes | ~$500–$2,000 |
| Skyvia | ⚠️ Partial | ✅ Yes | Subscription |
| DBSync | ✅ Yes | ✅ Yes | Enterprise |
Important consideration: Third-party tools reduce engineering effort but reduce visibility into exactly what transferred. Always run a post-migration record count and attachment presence audit regardless of which tool you use. For workflow automation that connects your migration process to downstream systems, Make.com offers flexible no-code automation that can orchestrate multi-step migration pipelines without custom code.
Zoho Export Reality:
├── Contacts.csv (has Zoho Contact ID)
├── Accounts.csv (has Zoho Account ID)
├── Activities.csv (has "Contact Name" text — NOT the ID)
├── Notes.csv (has "Parent Record" text — NOT always the ID)
└── Attachments (NOT EXPORTED AT ALL)
What you need:
Contacts.csv ──[Contact_ID]──► Activities.csv
Notes.csv
Attachments/
The standard CSV export does not guarantee that relationship columns contain IDs — many export as display names. If you import those into a target system, you get orphaned records with no programmatic link back to their parent.
Step 1: Export with IDs explicitly included
In Zoho CRM export settings, explicitly select "Record ID" as an export field — it is not included by default in many module exports.
# Validate your export has IDs — run this immediately after export
import pandas as pd
def validate_export_ids(filepath, module_name):
df = pd.read_csv(filepath)
required_id_columns = {
"Contacts": ["Contact ID", "Account Name", "Account ID"],
"Activities": ["Activity ID", "Contact ID", "Account ID"],
"Notes": ["Note ID", "Parent ID", "Contact ID"]
}
missing = []
for col in required_id_columns.get(module_name, []):
if col not in df.columns:
missing.append(col)
if missing:
print(f"⚠️ WARNING: {module_name} export missing columns: {missing}")
print("Re-export with these fields explicitly selected")
else:
print(f"✅ {module_name}: All relationship columns present")
return len(missing) == 0
Step 2: Build a cross-module ID mapping table
def build_relationship_map(contacts_df, activities_df, notes_df):
"""
Creates a master relationship map before import to target system.
Run this BEFORE any data moves to the target platform.
"""
relationship_map = {
"contacts": {},
"activities_by_contact": {},
"notes_by_contact": {}
}
for _, row in contacts_df.iterrows():
relationship_map["contacts"][row["Contact ID"]] = {
"name": row["Full Name"],
"account_id": row.get("Account ID"),
"email": row.get("Email")
}
for _, row in activities_df.iterrows():
contact_id = row.get("Contact ID")
if contact_id:
if contact_id not in relationship_map["activities_by_contact"]:
relationship_map["activities_by_contact"][contact_id] = []
relationship_map["activities_by_contact"][contact_id].append(
row["Activity ID"]
)
return relationship_map
Rather than relying on the UI export, pull data via API with relationships intact. This is the most reliable approach for any migration involving more than a few thousand records.
def export_contacts_with_relationships(access_token):
"""
Exports contacts WITH their related records in one structured output.
Output as JSON — not CSV — to preserve nested relationships.
"""
modules_to_relate = ["Activities", "Notes", "Attachments"]
contacts = get_all_records("Contacts", access_token)
enriched_contacts = []
for contact in contacts:
contact_id = contact["id"]
enriched = dict(contact)
for module in modules_to_relate:
related_url = f"https://www.zohoapis.com/crm/v8/Contacts/{contact_id}/{module}"
headers = {"Authorization": f"Zoho-oauthtoken {access_token}"}
related = requests.get(related_url, headers=headers).json()
enriched[f"related_{module}"] = related.get("data", [])
time.sleep(0.1) # Respect concurrency limits
enriched_contacts.append(enriched)
return enriched_contacts
Output this as JSON, not CSV. JSON preserves nested relationships that CSV flattens and destroys. A CSV of contacts with related notes becomes two disconnected flat files; a JSON export keeps the hierarchy intact.
```
Zoho Note created: 2021-03-15 09:23:00 UTC ← Original
Standard CSV export: 2021-03-15 09:23:00 UTC