---
title: "Freshdesk to SurveySparrow Ticket Migration: Technical Guide"
slug: freshdesk-to-surveysparrow-ticket-migration-technical-guide
date: 2026-08-07
author: Abdul
categories: [Freshdesk, Migration Guide, Help Desk]
excerpt: "Technical guide for migrating tickets from Freshdesk to SurveySparrow, covering API constraints, field mapping, and the edge cases that cause silent data loss."
tldr: "No native importer exists between Freshdesk and SurveySparrow. Migrate via custom API scripts — extract from Freshdesk v2, transform, load through SurveySparrow v3."
canonical: https://clonepartner.com/blog/freshdesk-to-surveysparrow-ticket-migration-technical-guide/
---

# Freshdesk to SurveySparrow Ticket Migration: Technical Guide


# Freshdesk to SurveySparrow Ticket Migration: Technical Guide

*Verified against Freshdesk API v2 and SurveySparrow API v3.*

Migrating from Freshdesk to SurveySparrow Ticket Management means translating a **full-lifecycle enterprise helpdesk** — with ticket hierarchies, groups, SLA policies, multi-product routing, and automation rules — into a **feedback-first ticketing module** designed around survey responses, NPS detractors, and form submissions. These two platforms model support data in fundamentally different ways, and there is no native migration path between them.

The SurveySparrow-Freshdesk integration on the Freshworks Marketplace triggers surveys when Freshdesk ticket events occur — it does not import historical ticket data. Zapier connects both platforms for real-time event-based workflows but cannot handle bulk migration of thousands of tickets with threaded conversations and attachments. No major third-party migration tool currently offers a verified Freshdesk-to-SurveySparrow Ticket Management connector.

Every migration requires extracting data via Freshdesk's REST API (v2), transforming the payload to match SurveySparrow's ticket schema, and loading it through SurveySparrow's Ticket API (v3). This guide covers the full technical path: API constraints on both sides, complete field mapping, extraction strategy, step-by-step process, idempotency handling, delta sync implementation, webhook suppression, timeline estimation, and the edge cases that silently corrupt your data.

> [!WARNING]
> **No native importer exists.** Do not attempt to use SurveySparrow's standard CSV import for a full helpdesk migration. The CSV importer only captures the initial ticket body — it cannot thread historical replies, map inline attachments, or preserve private notes. The SurveySparrow app on the Freshworks Marketplace is for triggering surveys from Freshdesk events, not importing ticket history. Plan for a custom API-to-API migration from day one.

For related migration paths, see our [How to Export Data from Freshdesk](https://clonepartner.com/blog/blog/how-to-export-data-from-freshdesk-methods-api-limits-mapping/), [JSON to SurveySparrow Ticket Migration Guide](https://clonepartner.com/blog/blog/json-to-surveysparrow-ticket-migration-technical-guide/), and [Zammad to SurveySparrow Ticket Migration Guide](https://clonepartner.com/blog/blog/zammad-to-surveysparrow-ticket-migration-technical-guide/).

## Freshdesk vs SurveySparrow: Architecture Differences That Affect Migration

Before writing any extraction scripts, understand how these data models diverge. Forcing Freshdesk data into SurveySparrow without structural translation results in orphaned records and lost context.

**Freshdesk** is a multi-channel enterprise helpdesk. Tickets are the central entity, linked to contacts, companies, groups, agents, products, and SLA policies. Conversations (replies and notes) are stored as separate objects on each ticket. Custom fields, tags, ticket types, and automation rules add layers of metadata. Freshdesk supports parent-child ticket relationships, tracker tickets, and custom statuses beyond the four defaults (Open, Pending, Resolved, Closed).

**SurveySparrow Ticket Management** is a module within a broader experience management platform. Tickets are typically generated from survey responses, NPS detractors, or CSAT complaints — though they can also be created manually or via API. The ticket model is flatter: tickets have a subject, description, priority, status, assignee, team, source, and custom fields. There is no native concept of companies, products, or ticket types.

| Concept | Freshdesk | SurveySparrow |
|---|---|---|
| **Central entity** | Ticket (enterprise lifecycle) | Ticket (feedback-driven) |
| **Conversations** | Separate `conversations` endpoint per ticket | Ticket Comments API |
| **Contacts** | Contacts + Companies (relational) | Contacts (flat) |
| **Agent structure** | Agents → Groups | Users → Teams |
| **Custom fields** | `custom_fields` object on ticket | `custom_fields` object on ticket |
| **Ticket types** | Configurable (Question, Incident, Problem, etc.) | Not natively supported |
| **Tags** | First-class array on ticket | No native tag field |
| **SLA policies** | Configurable per group/product/priority | Configurable per template |
| **Products** | Multi-product support | Not supported |
| **Ticket hierarchy** | Parent/child/tracker tickets | Parent/child ticket IDs |
| **Attachments** | Any file type, per conversation | pdf, png, jpeg, mp3, csv, wav — 15 MB max |
| **Subject length** | No documented hard cap | 200 characters max |
| **Webhooks on create** | Configurable | Fire on API-created tickets by default |

This table alone should tell you: this migration is a lossy translation unless you plan carefully for where Freshdesk data has no direct home in SurveySparrow.

### The Historical Timestamp Problem

SurveySparrow's Create Ticket API does not accept `created_at` or `updated_at` parameters. Every imported ticket carries the timestamp of the API call, not the original Freshdesk creation date. A ticket from 2021 migrated today will show today's date as `created_at` — and SurveySparrow's native sort, filter, and reporting features will all operate against this incorrect timestamp.

To preserve historical accuracy, create custom fields in SurveySparrow (e.g., `original_created_at`, `original_updated_at`) and map the Freshdesk timestamps there as ISO 8601 strings. Your reporting in SurveySparrow will need to reference these custom fields rather than the native system timestamps, which means any dashboard built on native `created_at` will be misleading for migrated data.

## API Constraints: Freshdesk (Source)

Freshdesk's REST API v2 is the only path that gives you full threaded data — tickets, conversations, contacts, and custom fields — in structured JSON.

**Authentication:** HTTP Basic Auth using your API key as the username and `X` as the password.

**Rate limits** are enforced per account (not per API key). Every script, installed app, and integration draws from the same per-minute bucket:

| Plan | Account-Level Limit (req/min) |
|---|---|
| Free | 100 |
| Growth | 200 |
| Pro | 400 |
| Enterprise | 700 |

Freshdesk also enforces **endpoint-specific sub-limits**. On Growth, for example, Ticket Create and Update are individually capped at 80/min, and List Tickets at 20/min — even though the account ceiling is 200/min. The rate limit remaining is returned in the `X-RateLimit-Remaining` response header; when it reaches zero, Freshdesk returns HTTP 429 with a `Retry-After` header specifying seconds to wait.

> [!WARNING]
> **Even failed requests count.** A 401 from a bad key or a 400 from a malformed body still consumes a call against your rate limit. Validate payloads before sending.

**Common Freshdesk API error codes during migration:**

| Code | Meaning | Migration Resolution |
|---|---|---|
| 400 | Bad request / malformed payload | Validate field types; check custom field names match exactly |
| 401 | Invalid API key or domain | Verify `Authorization` header format (API key + `X`) |
| 403 | Insufficient permissions | Check agent role — read-only agents cannot pull all ticket fields |
| 404 | Ticket not found | Ticket may be deleted; skip and log |
| 409 | Conflict / duplicate | Check idempotency — ticket with this ID may already exist |
| 429 | Rate limit exceeded | Read `Retry-After` header; implement exponential backoff |
| 500 | Freshdesk server error | Retry with backoff; log for manual review if persistent |

**Pagination:** The `GET /api/v2/tickets` endpoint returns 30 tickets per page by default (max 100 via `per_page`). The List All Tickets endpoint is hard-capped at **300 pages** — a ceiling of roughly **30,000 tickets**. For accounts with more than 30,000 tickets, you must use `updated_since` with time-windowed queries to pull all tickets incrementally.

**Filter-based extraction:** For targeted extraction (e.g., tickets by specific groups, tags, or custom field values), use the `/api/v2/search/tickets` endpoint with a `query` parameter supporting Elasticsearch-style expressions (`status:2 AND priority:3`, `created_at:>'2023-01-01'`). This endpoint returns up to 30 results per page with a maximum of 300 pages, and supports the same `per_page` parameter. Use this for partial migrations or validating subsets before a full run.

**Conversations are separate.** A single `GET /api/v2/tickets/{id}` request returns ticket metadata, not the full conversation thread. You must fetch conversations separately via `GET /api/v2/tickets/{id}/conversations`. The View Ticket endpoint's `include=conversations` parameter returns only up to 10 conversations and costs additional API credits per include.

**Status and Priority are integer-coded:**

| Freshdesk Status | Integer |
|---|---|
| Open | 2 |
| Pending | 3 |
| Resolved | 4 |
| Closed | 5 |

| Freshdesk Priority | Integer |
|---|---|
| Low | 1 |
| Medium | 2 |
| High | 3 |
| Urgent | 4 |

Freshdesk supports custom statuses on Growth+ plans, which use integers starting at 6. These must be explicitly included in your status mapping table — there is no fixed upper bound.

**Archived tickets:** Freshdesk automatically archives tickets after 120 days of inactivity. Archived tickets are not returned by the standard `/api/v2/tickets` endpoint. You must query the `/api/v2/tickets/archive` endpoint separately to ensure you don't leave years of historical data behind. The archive endpoint supports the same pagination parameters as the standard endpoint.

For a deep dive into Freshdesk export strategies, see our guide on [How to Export Data from Freshdesk](https://clonepartner.com/blog/blog/how-to-export-data-from-freshdesk-methods-api-limits-mapping/).

## API Constraints: SurveySparrow (Target)

SurveySparrow's v3 REST API uses **OAuth 2.0** for authentication. You generate an access token from Settings → Apps & Integrations. The base URL depends on your account's data center — contact SurveySparrow support if you're unsure which one to use.

**Ticket creation endpoints:**

- **Single ticket:** `POST /v3/tickets` — accepts `multipart/form-data`, required fields are `subject` (≤200 chars), `priority`, and `status`. Supports attachments (pdf, png, jpeg, mp3, csv, wav; 15 MB max per file).
- **Batch ticket creation:** `POST /v3/tickets/batch` — accepts a JSON array of ticket objects. Returns HTTP 202 with a `token` for polling batch status via `GET /v3/tickets/batch/status/{token}`. **Does not support attachments.**
- **Ticket Comments:** Separate endpoint at `/v3/ticket-comments` for adding threaded comments after ticket creation.

**Rate limits:** SurveySparrow does not publish exact per-plan rate limits in its public documentation. Based on observed migration behavior, standard plan accounts appear to sustain approximately 60 requests/min before receiving 429 responses — treat this as an empirical baseline, not a guarantee, and implement `Retry-After`-based backoff from the start. The API returns a `Retry-After` header on 429 responses.

**Common SurveySparrow API error codes during migration:**

| Code | Meaning | Migration Resolution |
|---|---|---|
| 400 | Invalid payload / missing required field | Check `subject`, `priority`, `status` are present and correctly typed |
| 401 | Invalid or expired OAuth token | Refresh token; check token scope covers ticket write operations |
| 404 | Resource not found | Verify ticket ID exists before adding comments |
| 413 | Payload too large | Attachment exceeds 15 MB or body is oversized; split or compress |
| 422 | Unprocessable entity | Subject likely exceeds 200 chars; custom field name mismatch |
| 429 | Rate limit exceeded | Read `Retry-After`; implement exponential backoff |
| 503 | Service unavailable | Retry with backoff; avoid bulk imports during SurveySparrow maintenance windows |

> [!NOTE]
> **Batch is faster but limited.** The batch endpoint is significantly faster for creating tickets without attachments, but you lose the ability to set `template_id` or upload files. If your migration includes attachments, you must use the single-ticket endpoint and handle rate limiting carefully.

**Key SurveySparrow constraints for migration:**

- **No timestamp override.** Every imported ticket carries the timestamp of the import. Store original timestamps in custom fields.
- **Subject is capped at 200 characters.** Freshdesk tickets with longer subjects will return HTTP 422. Log and truncate explicitly.
- **Attachment file types are restricted** to pdf, png, jpeg, mp3, csv, and wav. Freshdesk tickets with .docx, .xlsx, .zip, or other file types cannot be attached natively — convert, skip, or link to external storage.
- **Payload size limits.** Attachments exceeding 15 MB or large HTML bodies trigger `413 Payload Too Large`.
- **Custom fields must be pre-created.** Before importing tickets, create matching custom fields in SurveySparrow via Settings → Ticket Management → Ticket Fields, or via the Ticket Fields API. Attempting to write to a non-existent custom field returns a 422 without always naming the offending field.
- **Webhooks fire on API-created tickets by default.** If SurveySparrow outbound webhooks are configured (e.g., to Slack, email, or downstream systems), a bulk import of 10,000 tickets will trigger 10,000 webhook events. Disable or pause outbound webhooks in Settings → Integrations before running a bulk import. Re-enable after validation is complete.

## Field Mapping: Freshdesk → SurveySparrow

Every field needs an explicit mapping decision — map it directly, store it in a custom field, or drop it.

| Freshdesk Field | SurveySparrow Field | Notes |
|---|---|---|
| `id` | Custom field: `freshdesk_id` | Essential for idempotency checks and audit trail |
| `subject` | `subject` | Truncate to 200 chars; log any truncations |
| `description` / `description_text` | `description` | HTML or plain text — test both with your template config |
| `status` (integer) | `status` (integer) | Integers differ between platforms — build explicit lookup table |
| `priority` (integer) | `priority` (integer) | Integers may differ — verify against SurveySparrow Ticket Fields API |
| `requester_id` | `requester_id` or `email` | Must pre-create contacts in SurveySparrow first |
| `responder_id` | `assignee_id` | Map Freshdesk agent → SurveySparrow user ID |
| `group_id` | `team_id` | Map Freshdesk group → SurveySparrow team |
| `type` | Custom field | SurveySparrow has no native ticket type |
| `source` | `source` | Map integer values |
| `tags` | Custom field (multiselect or text) | No native tag field on SurveySparrow tickets |
| `company_id` | Custom field | No native company concept on SurveySparrow tickets |
| `product_id` | Custom field | No multi-product support |
| `custom_fields.*` | `custom_fields.*` | Pre-create matching fields; map internal names |
| `created_at` | Custom field: `original_created_at` | Store as ISO 8601 string |
| `updated_at` | Custom field: `original_updated_at` | Store as ISO 8601 string |
| `due_by` | Custom field or SLA config | No direct equivalent |
| `cc_emails` | Custom field (text) | Store as comma-separated string; SurveySparrow handles collaboration differently |
| `conversations` | Ticket Comments API | Each conversation becomes a separate comment |
| `attachments` | `attachments` on single create | File type + size restrictions apply |

> [!CAUTION]
> **Status and priority integers are not universal.** Freshdesk uses `2` for Open and `1` for Low priority. SurveySparrow uses its own integer mapping. Fetch SurveySparrow's status/priority definitions from the Ticket Fields API and build an explicit mapping table before writing any transformation code. Assuming the integers match will silently misclassify every ticket in the migration.

## Migration Methods: What Actually Works

### Method 1: Custom API-to-API Script (Recommended)

Extract from Freshdesk's REST API, transform in code, load via SurveySparrow's v3 API. This is the only method that preserves threaded conversations, attachments (within SurveySparrow's file-type limits), and custom field values.

**Best for:** Any migration where you need full fidelity — conversations, attachments, custom fields, and audit trails.

**Trade-off:** Requires development time (Python, Node.js, or similar). Rate limit management on both sides. Expect 2–5 days of development for a clean script, plus testing time.

### Method 2: Freshdesk CSV Export → Transform → SurveySparrow Batch API

Export tickets from Freshdesk's UI (Admin → Account → Export), transform the CSV, and push through SurveySparrow's batch endpoint.

**Best for:** Quick migrations of ticket metadata (subject, status, priority) where conversations and attachments don't matter.

**Trade-off:** Freshdesk's CSV export does not include full conversation histories or archived tickets. The SurveySparrow batch endpoint does not support attachments. You lose the most operationally valuable parts of your ticket history.

### Method 3: iPaaS (Zapier, Tray.io, n8n)

Both platforms have Zapier connectors.

**Best for:** Ongoing sync of *new* tickets between platforms after migration. Not suitable for bulk historical migration.

**Trade-off:** Zapier triggers on new Freshdesk tickets — it doesn't retroactively process existing ones. Even with custom Zaps using webhooks, you'll hit Zapier's task limits and both platforms' rate limits long before processing a meaningful historical backlog.

### Method 4: Hire a Migration Specialist

If your team lacks the engineering bandwidth or this migration is time-critical, a specialist team handles extraction, transformation, loading, and validation end-to-end.

**Best for:** Teams without dedicated developer time, large datasets (10K+ tickets), or strict compliance requirements where data integrity must be auditable.

## Step-by-Step Migration Process

### Step 1: Audit Your Freshdesk Data

Before extracting anything, quantify what you're moving:

- Total ticket count (including archived) — use `GET /api/v2/tickets?per_page=1` and read the `X-Total-Count` response header
- Conversation count per ticket (average and max)
- Attachment count, file types, and total file size
- Custom field inventory — field names, types, and option values
- Agent/group roster — `GET /api/v2/agents` and `GET /api/v2/groups`
- Custom statuses beyond the four defaults — `GET /api/v2/ticket_fields`
- Tags in use — `GET /api/v2/tags`
- Whether outbound webhooks are configured in SurveySparrow (disable before import)

### Step 2: Prepare SurveySparrow

1. **Disable outbound webhooks** in SurveySparrow Settings → Integrations before running any bulk import. A migration of 10,000 tickets will fire 10,000 webhook events to every connected downstream system (Slack, email notifications, third-party apps). Re-enable after validation.
2. **Create teams** matching your Freshdesk groups.
3. **Create users** matching your Freshdesk agents and map their IDs.
4. **Create contacts** matching Freshdesk contacts/requesters. Use the SurveySparrow Contacts API (`POST /v3/contacts`). When a contact with the same email already exists in SurveySparrow, the API will return a conflict. Implement a lookup-first pattern: query `GET /v3/contacts?email={email}` before creating. If a match is found with differing field values (e.g., different name or phone), log the conflict and apply a defined merge policy (Freshdesk values overwrite, or SurveySparrow values win) rather than creating a duplicate.
5. **Create custom ticket fields** for every Freshdesk field that has no native SurveySparrow equivalent: `freshdesk_id` (text, required for idempotency), `original_created_at`, `original_updated_at`, `ticket_type`, `tags`, `company_name`, `product`.
6. **Map status and priority integers.** Fetch SurveySparrow's current status/priority definitions from the Ticket Fields API (`GET /v3/ticket-fields`) and build a lookup table. Do not assume integer parity with Freshdesk.

### Step 3: Extract from Freshdesk

The extraction phase must follow a specific order to maintain relational integrity: **Agents → Contacts → Companies → Tickets → Conversations → Attachments.**

Use time-windowed queries to bypass the 30,000-ticket ceiling:

```python
import requests
import time
import json

FRESHDESK_DOMAIN = "yourcompany.freshdesk.com"
API_KEY = "your_api_key"

def get_tickets(updated_since, page=1):
    url = f"https://{FRESHDESK_DOMAIN}/api/v2/tickets"
    params = {
        "updated_since": updated_since,
        "per_page": 100,
        "page": page,
        "order_by": "updated_at",
        "order_type": "asc",
        "include": "description"
    }
    response = requests.get(url, auth=(API_KEY, "X"), params=params)
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 60))
        time.sleep(retry_after)
        return get_tickets(updated_since, page)
    response.raise_for_status()
    return response.json()

def get_conversations(ticket_id, page=1):
    url = f"https://{FRESHDESK_DOMAIN}/api/v2/tickets/{ticket_id}/conversations"
    params = {"per_page": 100, "page": page}
    response = requests.get(url, auth=(API_KEY, "X"), params=params)
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 60))
        time.sleep(retry_after)
        return get_conversations(ticket_id, page)
    response.raise_for_status()
    return response.json()

def get_archived_tickets(page=1):
    url = f"https://{FRESHDESK_DOMAIN}/api/v2/tickets/archive"
    params = {"per_page": 100, "page": page}
    response = requests.get(url, auth=(API_KEY, "X"), params=params)
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 60))
        time.sleep(retry_after)
        return get_archived_tickets(page)
    response.raise_for_status()
    return response.json()
```

> [!TIP]
> **Checkpoint as you go.** Write extracted tickets to a local JSON file or database after each page. If the script fails at page 200, you don't want to re-extract pages 1–199. Store the last successfully processed `updated_at` value — you'll need it for delta sync.

**Handling attachments:** Freshdesk attachments are provided as temporary Amazon S3 URLs that expire. You cannot pass the Freshdesk attachment URL to SurveySparrow. Your script must download the file to local storage during extraction and prepare it for upload during the import phase. If you extract ticket metadata but delay downloading attachments, the URLs will expire before you reach the loading phase.

### Step 4: Transform Data

Build a transformation layer that:

1. **Maps status/priority integers** using your lookup table.
2. **Truncates subjects** to 200 characters and logs each truncation with the original text.
3. **Strips or converts unsupported attachment types.** Log any skipped attachments and upload them to external storage (S3, GCS) with a link appended to the ticket description.
4. **Resolves requester IDs** — match Freshdesk `requester_id` to the SurveySparrow `contact_id` you created in Step 2, using email as the join key.
5. **Resolves agent/group IDs** — map Freshdesk `responder_id` → SurveySparrow `assignee_id`, and `group_id` → `team_id`.
6. **Packs unmapped fields into custom fields** — `freshdesk_id`, `original_created_at`, ticket type, tags, company name.
7. **Handles inline images.** Freshdesk users often paste screenshots directly into the rich-text editor, stored as `<img>` tags pointing to authenticated Freshdesk S3 URLs. These URLs require Freshdesk session authentication — SurveySparrow cannot resolve them, and they will render as broken images. Parse the HTML, download each image to local storage, upload to a public CDN, and rewrite the `<img src>` tags to the CDN URLs before importing. This step is required for any ticket with rich-text content.

```python
STATUS_MAP = {
    2: 1,  # Freshdesk Open → SurveySparrow equivalent (verify against /v3/ticket-fields)
    3: 2,  # Freshdesk Pending → SurveySparrow equivalent
    4: 3,  # Freshdesk Resolved → SurveySparrow equivalent
    5: 4,  # Freshdesk Closed → SurveySparrow equivalent
    # Add custom statuses (integers ≥ 6) here
}

def transform_ticket(fd_ticket, contact_map, agent_map, team_map):
    subject = fd_ticket["subject"]
    if len(subject) > 200:
        print(f"[TRUNCATE] Ticket {fd_ticket['id']}: subject truncated from {len(subject)} chars")
        subject = subject[:200]

    return {
        "subject": subject,
        "description": fd_ticket.get("description_text", ""),
        "status": STATUS_MAP.get(fd_ticket["status"], 1),
        "priority": fd_ticket.get("priority", 1),
        "email": contact_map.get(fd_ticket["requester_id"]),
        "assignee_id": agent_map.get(fd_ticket.get("responder_id")),
        "team_id": team_map.get(fd_ticket.get("group_id")),
        "custom_fields": {
            "freshdesk_id": str(fd_ticket["id"]),
            "original_created_at": fd_ticket["created_at"],
            "original_updated_at": fd_ticket["updated_at"],
            "ticket_type": fd_ticket.get("type", ""),
            "tags": ", ".join(fd_ticket.get("tags", [])),
            "cc_emails": ", ".join(fd_ticket.get("cc_emails", [])),
        }
    }
```

### Step 5: Load into SurveySparrow with Idempotency

Before creating any ticket, check whether it already exists in SurveySparrow by querying the `freshdesk_id` custom field. This prevents duplicate records when the script is restarted after a failure:

```python
import requests

SS_BASE_URL = "https://api.surveysparrow.com"  # Adjust for your data center
SS_TOKEN = "your_oauth_token"

def find_existing_ticket(freshdesk_id):
    """Check if a ticket with this freshdesk_id custom field already exists."""
    url = f"{SS_BASE_URL}/v3/tickets"
    headers = {"Authorization": f"Bearer {SS_TOKEN}"}
    params = {"custom_fields[freshdesk_id]": str(freshdesk_id)}
    response = requests.get(url, headers=headers, params=params)
    response.raise_for_status()
    results = response.json().get("data", [])
    return results[0] if results else None

def create_tickets_batch(tickets):
    url = f"{SS_BASE_URL}/v3/tickets/batch"
    headers = {
        "Authorization": f"Bearer {SS_TOKEN}",
        "Content-Type": "application/json"
    }
    response = requests.post(url, json=tickets, headers=headers)
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 60))
        time.sleep(retry_after)
        return create_tickets_batch(tickets)
    if response.status_code == 202:
        return response.json()["token"]  # Poll via GET /v3/tickets/batch/status/{token}
    response.raise_for_status()

def create_ticket_with_attachment(ticket_data, attachment_path):
    url = f"{SS_BASE_URL}/v3/tickets"
    headers = {"Authorization": f"Bearer {SS_TOKEN}"}
    with open(attachment_path, "rb") as f:
        files = {"attachments": f}
        response = requests.post(url, data=ticket_data, files=files, headers=headers)
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 60))
        time.sleep(retry_after)
        return create_ticket_with_attachment(ticket_data, attachment_path)
    response.raise_for_status()
    return response.json()

def migrate_ticket(fd_ticket, transformed_ticket, attachment_paths=None):
    """Idempotent ticket creation: skip if freshdesk_id already exists."""
    existing = find_existing_ticket(fd_ticket["id"])
    if existing:
        print(f"[SKIP] Ticket {fd_ticket['id']} already migrated as SS ID {existing['id']}")
        return existing

    if attachment_paths:
        return create_ticket_with_attachment(transformed_ticket, attachment_paths[0])
    else:
        # Use batch endpoint for efficiency; fall back to single if batch fails
        return create_tickets_batch([transformed_ticket])
```

**Ticket status sequencing matters.** If a ticket was closed in Freshdesk, create it in SurveySparrow as Open first, add all historical replies as comments, and then make a final `PUT /v3/tickets/{id}` request to set the status to Closed. Creating a ticket in Closed status initially may cause the API to reject subsequent comment additions on some plan configurations.

### Step 6: Import Conversations as Ticket Comments

After each ticket is created in SurveySparrow (and you've captured the new ticket ID), import Freshdesk conversations as Ticket Comments. Maintain the original chronological order from Freshdesk — the `conversations` endpoint returns them oldest-first by default.

```python
def add_comment(ss_ticket_id, comment_body, is_private=False):
    url = f"{SS_BASE_URL}/v3/tickets/{ss_ticket_id}/comments"
    headers = {
        "Authorization": f"Bearer {SS_TOKEN}",
        "Content-Type": "application/json"
    }
    payload = {
        "body": comment_body,
        "is_private": is_private
    }
    response = requests.post(url, json=payload, headers=headers)
    if response.status_code == 429:
        retry_after = int(response.headers.get("Retry-After", 60))
        time.sleep(retry_after)
        return add_comment(ss_ticket_id, comment_body, is_private)
    response.raise_for_status()

def import_conversations(ss_ticket_id, conversations):
    """Import Freshdesk conversations as SurveySparrow comments in chronological order."""
    for conv in sorted(conversations, key=lambda x: x["created_at"]):
        author = conv.get("user_name") or conv.get("from_email", "Unknown")
        date = conv["created_at"]
        body = f"[Originally posted by: {author} — {date}]\n\n{conv.get('body_text', '')}"
        is_private = conv.get("private", False)
        add_comment(ss_ticket_id, body, is_private=is_private)
```

> [!NOTE]
> **Author attribution:** Comments created via API are attributed to the API user making the request. Preserve original authorship by prepending `[Originally posted by: Name — Date]` to each comment body. Map the Freshdesk `private` boolean directly to the `is_private` field to preserve internal notes vs. customer-visible replies.

### Step 7: Delta Sync for the Migration Window

During a live migration, Freshdesk tickets continue to be created and updated. Run a delta sync after the initial load completes to capture changes that occurred during the migration window.

The delta sync relies on the `updated_since` parameter and the `freshdesk_id` custom field you stored during the initial load:

```python
def delta_sync(migration_start_timestamp, contact_map, agent_map, team_map):
    """
    Re-process all tickets updated since migration started.
    Uses freshdesk_id to upsert: update existing SS tickets, create any missed.
    """
    page = 1
    while True:
        tickets = get_tickets(updated_since=migration_start_timestamp, page=page)
        if not tickets:
            break

        for fd_ticket in tickets:
            transformed = transform_ticket(fd_ticket, contact_map, agent_map, team_map)
            existing = find_existing_ticket(fd_ticket["id"])

            if existing:
                # Update existing SurveySparrow ticket with latest Freshdesk state
                url = f"{SS_BASE_URL}/v3/tickets/{existing['id']}"
                headers = {
                    "Authorization": f"Bearer {SS_TOKEN}",
                    "Content-Type": "application/json"
                }
                requests.put(url, json=transformed, headers=headers)
                print(f"[UPDATE] Freshdesk {fd_ticket['id']} → SS {existing['id']}")
            else:
                # Ticket created in Freshdesk after initial extraction began
                migrate_ticket(fd_ticket, transformed)
                print(f"[CREATE] Freshdesk {fd_ticket['id']} (new during migration window)")

        page += 1

    print("[DELTA SYNC COMPLETE]")
```

Run the delta sync during a low-traffic period (e.g., weekend overnight). After delta sync completes and validation passes, update your DNS/routing and decommission Freshdesk access.

### Step 8: Validate

After loading, run a reconciliation check:

- **Count match:** Query `GET /api/v2/tickets?per_page=1` (read `X-Total-Count` header) vs total tickets in SurveySparrow. Include archived tickets in the Freshdesk count.
- **Conversation count match:** For a sample of 50–100 tickets, compare Freshdesk conversation count vs SurveySparrow comment count.
- **Spot-check 20–50 tickets** across different statuses, priorities, agents, and date ranges. Prioritize tickets that had attachments, inline images, private notes, and custom fields.
- **Verify custom field values** didn't get silently dropped: check `freshdesk_id`, `original_created_at`, `tags`, and `ticket_type` on a sample set.
- **Check attachment presence** on tickets that had them in Freshdesk. Verify file type handling — confirm unsupported types were either converted or linked.
- **Verify no webhook storms occurred:** Check your downstream systems (Slack, email) for unexpected notification volume during the import window.
- **Confirm webhook re-enablement** after validation passes.

## Edge Cases and Silent Failure Modes

These are the issues that won't throw errors but will corrupt your data:

1. **Archived tickets in Freshdesk.** The List All Tickets and Filter Tickets endpoints exclude archived tickets. Query the `/api/v2/tickets/archive` endpoint separately, or you'll leave years of historical data behind with no error to alert you.

2. **Freshdesk custom statuses (integers ≥ 6).** If you've added custom statuses in Freshdesk, your status mapping table must include them. The default four-value map will silently mismap these tickets to whatever integer 6 resolves to in your table.

3. **SurveySparrow subject truncation.** A 250-character Freshdesk subject will return HTTP 422 or get silently truncated depending on API version. Log all truncations explicitly with original and truncated values.

4. **Attachment file type mismatches.** A `.docx` or `.zip` attachment from Freshdesk has no home in SurveySparrow's allowed types (pdf, png, jpeg, mp3, csv, wav). Log skipped attachments, download them to external storage (S3, GCS), and append a link in the ticket description.

5. **Inline images.** Freshdesk users often paste screenshots directly into the rich-text editor. These are stored as `<img>` tags pointing to authenticated Freshdesk S3 URLs. SurveySparrow cannot authenticate against Freshdesk to load them — they render as broken images with no error. Parse the HTML, download each image, upload to a public CDN, and rewrite `<img src>` tags before importing. This affects the majority of tickets in support-heavy Freshdesk accounts.

6. **Conversation ordering.** Freshdesk returns conversations in chronological order. If you process them out of order (e.g., using async parallelism without sorting), the thread reads backwards in SurveySparrow.

7. **CC and BCC fields.** Freshdesk heavily utilizes CCs for ticket visibility. SurveySparrow handles collaboration differently. Map the Freshdesk CC email array into a custom text field (comma-separated) so the context isn't lost.

8. **HTML in descriptions.** Freshdesk stores `description` as HTML and `description_text` as plain text. SurveySparrow's description field behavior depends on your template configuration. Test both formats in a sandbox before committing to one for the full migration.

9. **Rate limit collision on Freshdesk.** If other integrations or apps are running against your Freshdesk account during extraction, they share the same rate limit bucket. Schedule extraction during off-hours or temporarily disable non-essential integrations.

10. **Contact deduplication conflict.** If the same customer email exists as both a Freshdesk contact and a SurveySparrow contact, a naive import creates duplicates. Implement a lookup-first pattern and apply a defined merge policy before importing.

11. **Expired attachment URLs.** Freshdesk serves attachments via temporary S3 URLs. Download attachments during the extraction phase. Do not defer download to the loading phase — the URLs will expire.

12. **Webhook storm during bulk import.** SurveySparrow fires outbound webhooks on API-created tickets by default. A 10,000-ticket import triggers 10,000 webhook events to every configured downstream system. Disable webhooks before bulk import; re-enable after validation.

13. **Script restart without idempotency.** If your migration script fails at ticket 5,000 of 10,000 and you restart it from the beginning without checking for existing records, you'll create 5,000 duplicates. The `freshdesk_id` custom field + lookup-before-create pattern in Step 5 prevents this.

## How Long Does This Migration Take?

Timeline depends on three variables: ticket volume, conversation density, and attachment count.

| Volume | Estimated Duration | Notes |
|---|---|---|
| < 1,000 tickets | 1–2 days | Script development + single run |
| 1,000–10,000 tickets | 3–5 days | Chunked extraction, batch loading |
| 10,000–50,000 tickets | 1–2 weeks | Rate limit pacing dominates; time-windowed extraction required |
| 50,000–100,000 tickets | 2–4 weeks | Consider Account Export for initial pull + API for conversations |

The bottleneck is almost always the **extraction side**. Fetching conversations requires one API call per ticket — at 10,000 tickets with Freshdesk Growth-tier limits (200 req/min account ceiling, 20 req/min for list operations), just the conversation fetch takes approximately 50 minutes of pure API time, not counting ticket list calls, retries, or the Freshdesk endpoint sub-limit collisions.

A typical mid-sized migration (50,000–100,000 tickets) breaks down roughly as:

- **API discovery and mapping (1 week):** Defining custom fields, mapping statuses, setting up the staging environment, auditing custom statuses and attachment types.
- **Script development (1–2 weeks):** Writing extraction pagination logic, attachment handling, inline image parsing, idempotency checks, and delta sync logic.
- **Test migration (1 week):** Pushing a subset (e.g., 5,000 tickets) to a SurveySparrow sandbox to verify formatting, inline images, timestamp accuracy, and webhook suppression.
- **Delta sync and cutover (weekend):** Running the final extraction, pushing remaining data, performing delta sync for tickets updated during the migration window, and running validation counts.

## When Not to Do This Migration

Be honest about whether SurveySparrow Ticket Management is the right target:

- **If your team relies on Freshdesk's multi-product routing, SLA escalation chains, or deep automation rules,** SurveySparrow's ticket module may not cover those workflows. Audit your automation rules before committing — rules that reference product IDs, custom status integers ≥ 6, or ticket type fields will have no equivalent in SurveySparrow without custom field workarounds.
- **If you need full timestamp preservation,** SurveySparrow's API does not support historical timestamp imports. Every ticket will show the import date as `created_at`. Custom fields preserve the data but not the native sort/filter behavior — any date-range filter in SurveySparrow's UI will produce incorrect results for migrated tickets.
- **If you have many custom statuses or complex ticket type hierarchies,** the mapping work may exceed the value of the migration.
- **If outbound webhook integrations are deeply embedded in your workflow,** the webhook suppression requirement during bulk import may cause a service interruption. Plan for this explicitly.

SurveySparrow's strength is in **closing the feedback loop** — connecting survey data to support actions. If that's your goal, the migration is worth the effort. If you need a full-featured standalone helpdesk, evaluate whether the ticket management module meets your requirements first. If you've already outgrown it, see our guides on migrating from SurveySparrow to [Zendesk](https://clonepartner.com/blog/blog/surveysparrow-to-zendesk-migration-the-technical-guide/) or [Missive](https://clonepartner.com/blog/blog/surveysparrow-ticket-management-to-missive-migration-technical-guide/).

## Making It Happen

This migration is technically straightforward but operationally tedious. The API surface on both sides is documented. The hard part is the data transformation — mapping every field, handling edge cases, implementing idempotency, suppressing webhooks, and validating that nothing was silently dropped.

At ClonePartner, we've built migration scripts for this exact platform pair. We handle the extraction, transformation, validation, and loading — including inline image rewrites, attachment conversion, contact deduplication, webhook suppression, delta sync, and timestamp preservation in custom fields.

> Need to migrate from Freshdesk to SurveySparrow without losing historical data, attachments, or inline images? Get a free 30-minute migration assessment — we'll map your data model and give you an honest timeline.
>
> [Talk to us](https://cal.com/clonepartner/meet?duration=30)

## Frequently asked questions

### Is there a native migration tool from Freshdesk to SurveySparrow?

No. The SurveySparrow integration on the Freshworks Marketplace only triggers surveys from Freshdesk ticket events. It does not import historical ticket data. Every migration requires a custom API-to-API approach using Freshdesk's REST API v2 for extraction and SurveySparrow's v3 API for loading.

### Can SurveySparrow preserve original Freshdesk ticket timestamps?

Not directly. SurveySparrow's Create Ticket API does not accept created_at or updated_at parameters. Imported tickets will carry the import date as their creation timestamp. To preserve original dates, store them in custom fields like original_created_at on the SurveySparrow ticket.

### What Freshdesk data is lost when migrating to SurveySparrow?

SurveySparrow Ticket Management has no native equivalent for Freshdesk ticket types, tags, companies, products, or multi-product routing. These values must be stored in custom fields or dropped. Attachment support is limited to pdf, png, jpeg, mp3, csv, and wav files with a 15 MB max. Unsupported file types need alternative handling.

### Does SurveySparrow's batch ticket API support attachments?

No. The POST /v3/tickets/batch endpoint accepts JSON and does not support file uploads. Tickets with attachments must be created one at a time using the single-ticket POST /v3/tickets endpoint with multipart/form-data.

### How long does a Freshdesk to SurveySparrow migration take?

For under 1,000 tickets, expect 1–2 days including script development. For 10,000–50,000 tickets, plan for 1–2 weeks due to rate limit pacing on both platforms. The extraction side is typically the bottleneck since Freshdesk conversations require one API call per ticket.
