---
title: "Freshchat to Groove Migration: A Technical Guide"
slug: freshchat-to-groove-migration-a-technical-guide
date: 2026-07-27
author: Wahab
categories: [Migration Guide, Groove]
excerpt: "Technical guide to migrating from Freshchat to Groove: data model mapping, API constraints, chat-to-ticket transformation, rate limits, and step-by-step process."
tldr: "Freshchat to Groove migration requires translating conversation-message streams into ticket-reply threads via API. No native path exists — plan for schema transformation, message grouping, rate-limit management, and attachment re-hosting."
canonical: https://clonepartner.com/blog/freshchat-to-groove-migration-a-technical-guide/
---

# Freshchat to Groove Migration: A Technical Guide


# Freshchat to Groove Migration: A Technical Guide

Migrating from Freshchat to Groove means translating a **conversation-centric messaging platform** into a **ticket-centric shared inbox**. There is no native migration path, no vendor-provided connector, and no push-button import between these two systems. Every record — users, conversations, messages, agents, groups, attachments — requires extraction from [Freshchat's REST API v2](https://developers.freshchat.com/api/), structural transformation to match Groove's data model, and loading through [Groove's REST v1 or GraphQL v2 API](https://www.groovehq.com/docs).

This is not a CSV copy. It is a schema translation project where the fundamental unit of data changes shape: Freshchat stores a stream of messages inside a conversation object; Groove stores a ticket with threaded replies. Groove's official migration documentation points customers to Import2 or the API, and Freshchat is not listed in Groove's documented Import2 source list — so a direct, vendor-endorsed Freshchat-to-Groove path does not currently exist.

This guide covers data model translation, API constraints, extraction strategies, and the specific engineering challenges of converting continuous chat streams into readable email-style tickets.

## Why Teams Move from Freshchat to Groove

**Freshchat** is Freshworks' omnichannel messaging platform for customer engagement across web chat, WhatsApp, Facebook Messenger, Instagram, Apple Business Chat, and email. Its data model is conversation-first: every interaction is a continuous stream of individual messages, each with its own sender, timestamp, and message type. Freshchat sits within the broader Freshworks ecosystem (Freshdesk, Freshsales, Freshservice) and includes features like bots, IntelliAssign, and WhatsApp templates. Pricing starts at $19/agent/month (Growth plan) and rises to $79/agent/month (Enterprise), with additional costs for bot sessions and WhatsApp message credits.

**Groove** (GrooveHQ) is a lightweight shared inbox and help desk built for small-to-mid-sized teams. It organizes customer communications into tickets across email, live chat, social channels, and phone — with a Gmail-like interface that prioritizes simplicity over feature density. Pricing runs $16–$56/user/month across Standard, Plus, and Pro tiers.

Common reasons teams migrate:

- **Simplicity over complexity.** Teams that primarily need email-based support with light chat find Freshchat's omnichannel feature set — bot builders, WhatsApp template management, IntelliAssign routing — to be unnecessary overhead. Groove's intentionally constrained feature set reduces configuration burden.
- **Cost reduction.** A 10-agent team on Freshchat Enterprise ($79/agent/month) pays $790/month before bot session add-ons. The same team on Groove Plus ($32/user/month) pays $320/month — a 59% reduction.
- **Consolidation away from Freshworks.** Teams leaving the Freshworks ecosystem entirely no longer benefit from Freshchat's tight integration with Freshsales or Freshdesk, making it a standalone cost without ecosystem value.
- **Preference for email-first support.** Groove's shared inbox model mirrors email workflows. Teams whose support volume is 70%+ email find Groove's paradigm more natural than Freshchat's chat-first interface.

The technical challenge lies in the paradigm mismatch: dumping raw chat logs into a ticketing system creates an unreadable, fragmented mess. The transformation layer is where this migration succeeds or fails.

## Data Model Differences: Freshchat vs. Groove

Understanding the structural mismatch is the single most important step before writing any migration code.

| Concept | Freshchat | Groove |
|---|---|---|
| **Core unit** | Conversation (stream of messages) | Ticket (threaded replies) |
| **Customer record** | User (`/v2/users`) | Customer |
| **Support staff** | Agent (`/v2/agents`) | Agent |
| **Routing** | Groups + Channels (Topics) | Mailboxes + Folders + Groups |
| **Message types** | normal, private, system, bot | Customer reply, agent reply, note |
| **Custom fields** | Conversation properties (`cf_` prefix) | Custom ticket fields, tags |
| **Satisfaction** | CSAT (1–5 rating per conversation) | Satisfaction ratings |
| **Attachments** | Files/Images (S3 bucket, 25 MB limit) | Attachments on tickets (25 per message, 20 MB per file) |
| **Statuses** | new, assigned, resolved, reopened | open, pending, closed (varies by API version) |
| **API style** | REST v2 (Bearer token) | REST v1 (access token) + GraphQL v2 |

> [!WARNING]
> **Key structural mismatch:** A single Freshchat conversation may contain dozens of messages from users, agents, bots, and system events. In Groove, this must become a single ticket with ordered replies. Bot interactions, carousel cards, quick-reply buttons, and other rich message parts have no equivalent in Groove's ticket model — they must be flattened to plain text or dropped.

## Migration Approaches

### API-Based Migration (Freshchat REST v2 → Groove REST v1 / GraphQL v2)

**How it works:** Build a custom script that extracts all data from Freshchat's v2 API endpoints, transforms it to match Groove's data model, then loads it through Groove's API.

**When to use it:** Any migration involving more than a few hundred conversations, or where you need full control over data mapping, timestamps, and message ordering.

- **Pros:** Full control over mapping logic. Can preserve timestamps, relationships, and message ordering. Handles custom fields and conversation properties.
- **Cons:** Requires development effort (Python/Node.js). Rate limits on both sides constrain throughput. Groove's REST v1 is no longer in active development, and the GraphQL v2 Inbox APIs are still being built.
- **Complexity:** High

### Native Export / Manual Import (CSV)

**How it works:** Export Freshchat data as CSV via `POST /reports/raw` (event/report oriented), flatten conversations into rows, and attempt to import into Groove.

**When to use it:** Very small datasets where you only need contact lists or low-fidelity conversation history for reference.

- **Pros:** No code required for extraction. Low technical barrier.
- **Cons:** Freshchat's CSV exports are flat transcript logs, not structured ticket data. Groove has no documented bulk CSV ticket import tool. Loses relationships between users, agents, and conversations. Cannot reconstruct message threads, attachments, or chronological agent replies.
- **Complexity:** Medium (deceptively simple — the transformation work is significant)

### Middleware / Integration Platforms (Zapier, Make)

**How it works:** Use Zapier or Make to connect Freshchat triggers to Groove actions, forwarding new conversations as they arrive. Make has Freshchat modules, and Groove has an official Zapier integration.

**When to use it:** For ongoing, real-time sync during a phased transition. Not suitable for historical data migration.

- **Pros:** No-code setup. Good for bridging during a transition period.
- **Cons:** Cannot migrate historical data. Limited field mapping. Per-task pricing gets expensive at volume (Make charges per operation; 10,000 conversations × ~5 operations each = 50,000 operations). Cannot handle complex message threading or message grouping.
- **Complexity:** Low (for forward-only sync)

### Third-Party Migration Services

**How it works:** Managed migration services handle extraction, transformation, and loading as a project.

**When to use it:** When engineering bandwidth is limited, data volume is high, or the migration is business-critical with no room for error.

- **Pros:** Minimal internal engineering effort. Handles edge cases (duplicates, attachments, relationship mapping). Faster time-to-completion.
- **Cons:** External cost. Less control over transformation logic (though good vendors provide visibility).
- **Complexity:** Low (for your team)

### Custom ETL Pipeline

**How it works:** Build a full extract-transform-load pipeline using tools like dlt, Apache Airflow, or custom scripts with staging databases.

**When to use it:** Enterprise-scale migrations with tens of thousands of conversations, complex business rules, or regulatory requirements.

- **Pros:** Full audit trail. Replayable and testable. Can handle incremental/delta migrations.
- **Cons:** Highest engineering investment. Requires infrastructure (staging DB, job scheduling).
- **Complexity:** High

### Approach Comparison

| Method | Historical Data | Ongoing Sync | Attachments | Complexity | Best For |
|---|---|---|---|---|---|
| API-based script | ✅ | Manual re-run | ✅ | High | Most migrations |
| CSV export/import | Partial | ❌ | ❌ | Medium | Contacts-only, tiny datasets |
| Zapier/Make | ❌ | ✅ | Limited | Low | Transition bridging |
| Third-party service | ✅ | Varies | ✅ | Low (your team) | Time-constrained teams |
| Custom ETL | ✅ | ✅ | ✅ | High | Enterprise scale |

### Recommendations by Scenario

- **Small business, low engineering bandwidth:** Third-party migration service or accept a fresh start, keeping Freshchat open as a read-only archive.
- **Small business, dev-capable team:** API-based script.
- **Enterprise / high volume (>25K conversations):** Custom ETL pipeline or managed service. The data transformation logic for chat-to-email mapping is too complex for basic tools.
- **Transition period:** Zapier/Make for forward flow + API script or managed service for historical backfill.
- **Low engineering bandwidth:** Do not attempt a custom build. The hidden costs of API rate limit management, schema mapping, and attachment handling will derail your sprint cycle.

## Pre-Migration Planning

Start with a **data audit**, not a script.

### Data Audit Checklist

Before extracting anything, inventory what you have in Freshchat:

- [ ] **Users (customers):** Total count, custom properties, email coverage (what percentage have email addresses vs. anonymous?)
- [ ] **Agents:** Active vs. deactivated, group memberships
- [ ] **Conversations:** Total count by status (new, assigned, resolved, reopened)
- [ ] **Messages per conversation:** Average depth, max depth (critical for estimating API call volume)
- [ ] **Groups and Channels (Topics):** Mapping to Groove mailboxes/folders
- [ ] **Custom conversation properties:** Field types, picklist values (only available for Freshchat accounts within the Freshsales Suite)
- [ ] **CSAT ratings:** Volume. Note: Groove does not expose a programmatic CSAT import endpoint in its current API documentation — raw ratings may need to be stored as tags or custom fields rather than native satisfaction records.
- [ ] **Attachments and files:** Total volume, file types, sizes
- [ ] **Bot interactions:** Volume of bot-handled conversations (percentage of total)
- [ ] **Outbound WhatsApp messages:** These have no Groove equivalent

### Define Migration Scope

Not everything should move. Consider excluding:

- **Bot-only conversations** with no human agent involvement
- **System messages** (auto-assignment notifications, IntelliAssign events)
- **Resolved conversations older than X months** if they have no business value
- **Outbound WhatsApp message templates** (no Groove equivalent)

Filtering these out reduces API call volume — which directly impacts migration duration given Freshchat's rate limits — and keeps Groove tickets clean.

### Migration Strategy

| Strategy | When to Use |
|---|---|
| **Big bang** | Small dataset (<5K conversations), short cutover window acceptable |
| **Phased** | Large dataset, migrate by date range or channel |
| **Parallel run** | Risk-averse teams, run both platforms during transition |

Freshchat's `updated_from` filters and message `from_time` pagination support incremental extraction, which enables phased and delta migration strategies.

## Data Mapping: Freshchat Objects → Groove Objects

### Core Object Mapping

| Freshchat Object | Freshchat Endpoint | Groove Equivalent | Notes |
|---|---|---|---|
| User | `/v2/users` | Customer | Map email, name, phone. Custom properties → customer custom fields or tags |
| Agent | `/v2/agents` | Agent | Must be manually created in Groove first; map by email |
| Conversation | `/v2/conversations/{id}` | Ticket | One conversation = one ticket |
| Message (normal, actor_type=user) | `/v2/conversations/{id}/messages` | Customer reply | Flatten message_parts to text |
| Message (normal, actor_type=agent) | `/v2/conversations/{id}/messages` | Agent reply | Flatten message_parts to text |
| Message (private) | `/v2/conversations/{id}/messages` | Internal note | |
| Message (system) | `/v2/conversations/{id}/messages` | Drop or append as note | No direct equivalent |
| Group | `/v2/groups` | Group / Folder | |
| Channel (Topic) | `/v2/channels` | Mailbox or Tag | Depends on Groove structure |
| Conversation Property | `/conversations/fields` | Custom ticket field or Tag | Limited by Groove's field types |
| CSAT Rating | `/csat/{id}` | Tag or custom field | No confirmed programmatic CSAT import in Groove's current API |
| File/Image attachment | Message parts | Ticket attachment | Download from S3 URL, re-upload to Groove |

### Status Mapping

| Freshchat Status | Groove Status |
|---|---|
| `new` | Open |
| `assigned` | Open (with assignee) |
| `resolved` | Closed |
| `reopened` | Open |

> [!WARNING]
> **Status mapping caveat:** Groove's current help docs describe Open/Snoozed/Closed operational states, while the legacy REST v1 API exposes `unread`, `opened`, `pending`, `closed`, and `spam` as valid state values. These are two different status systems depending on which API version you target. Test your exact target account behavior before hard-coding status transforms — create a test ticket via API and verify the returned state values match your mapping.

### Field-Level Mapping

| Freshchat Field | Type | Groove Field | Transformation |
|---|---|---|---|
| `user.email` | string | `customer.email` | Direct map, lowercase + dedupe |
| `user.first_name` + `user.last_name` | string | `customer.name` | Concatenate |
| `user.phone` | string | `customer.phone` | Direct map |
| `user.properties []` | array | Tags or custom fields | Iterate and map |
| `conversation.status` | enum | `ticket.state` | Value map (see above) |
| `conversation.properties.priority` | enum | Tag or custom field | Groove's `POST /v1/tickets` does not accept a `priority` parameter; use tags (e.g., `priority:high`) |
| `conversation.assigned_agent_id` | UUID | `ticket.assignee` | Resolve agent email → Groove agent ID |
| `conversation.channel_id` | UUID | `ticket.mailbox` or tag | Resolve channel name → Groove mailbox |
| `message.message_parts [].text.content` | string | Reply body | Concatenate all text parts |
| `message.message_parts [].file` | object | Attachment | Download URL, re-upload |
| `message.created_time` | UTC timestamp | Reply timestamp | Direct map |

### The Chat-to-Ticket Transformation Challenge

In Freshchat, a user might send five distinct messages in 30 seconds:

1. "Hi"
2. "I have a problem"
3. "My order didn't arrive"
4. "Order #12345"
5. "Can you help?"

If you migrate these as five separate replies into a Groove ticket, the UI becomes cluttered and difficult to read — each message occupies its own reply card with header, timestamp, and avatar, turning a brief exchange into a visually overwhelming wall. Your transformation layer should group messages from the same sender that occur within a short time window (e.g., 2 minutes) into a single HTML text block before pushing to Groove. This preserves readability in an email-style interface.

Bot interactions present a separate challenge. Freshchat bot messages can contain carousels, quick-reply buttons, dropdowns, and text inputs — none of which have equivalents in Groove's plain text/HTML reply model. Flatten these to descriptive text (e.g., `[Bot: presented options — Option A, Option B, Option C. User selected: Option B]`) or drop them entirely. Document your choice so agents reviewing historical tickets understand what was lost.

## Migration Architecture

The data flow follows a standard ETL pattern:

```
Freshchat API (v2)  →  Staging Layer  →  Groove API (v1 / GraphQL v2)
     Extract              Transform              Load
```

Never stream directly from Freshchat to Groove. Always extract to a staging database first (SQLite for small datasets, PostgreSQL for >10K conversations). This protects you against network failures, rate limits, and expiring attachment URLs, and it lets you re-run transformation logic without re-querying the source API.

### Extract Phase: Freshchat API

**Authentication:** Bearer token in Authorization header. Generate from Admin > Configure > API Tokens in the Freshchat dashboard.

**Base URL:** `https://<account>.freshchat.com/v2/`

**Key endpoints:**
- `GET /v2/agents` — all agents (paginated)
- `GET /v2/users` — all users (filterable by `created_from`/`created_to` date range)
- `GET /v2/groups` — all groups
- `GET /v2/channels` — all channels/topics
- `GET /v2/conversations/{id}` — single conversation metadata
- `GET /v2/conversations/{id}/messages` — messages in a conversation (max 50 per page)
- `GET /v2/users/{user_id}/conversations` — all conversation IDs for a user
- `POST /reports/raw` — bulk report extraction (CSV)

**Rate limits:** Per-account, varies by plan. The Reports API has a documented limit of 100 calls per minute. Monitor `X-RateLimit-Remaining` and `X-RateLimitReset` response headers. HTTP 429 returned when exceeded. Implement exponential backoff starting at 1 second, doubling per retry, with a maximum of 5 retries.

**Pagination:** All list endpoints use `page` and `items_per_page` query parameters. Messages endpoint caps at 50 items per page. Follow `links.next_page.href` until the `next_page` key is absent from the response.

> [!NOTE]
> **Extraction bottleneck:** There is no bulk "list all conversations" endpoint in Freshchat's public API. You must either: (a) iterate through users and call `/v2/users/{user_id}/conversations` for each, or (b) use the Reports API (`POST /reports/raw` with event `Chat-Transcript`) to get conversation IDs in bulk, then fetch details per conversation. The Reports API is limited to 24-hour windows for transcript data and 1-month windows for other event types, meaning a 1-year backfill requires at minimum 365 sequential API calls just for conversation discovery.

### Estimated Extraction Duration

To help scope your project: extracting 10,000 conversations with an average of 15 messages each requires approximately:

- **Conversation discovery:** 10,000 user-level queries (or ~365 Reports API calls for a 1-year window)
- **Message extraction:** 10,000 conversations × ~1 page of messages each = 10,000 calls (more if conversations exceed 50 messages)
- **At Freshchat's 100-calls/minute rate limit:** ~200 minutes (3.3 hours) for message extraction alone, assuming no 429 retries

Plan for 6–12 hours of wall-clock extraction time for a 10K-conversation dataset, including retries and attachment downloads.

### Transform Phase

This is where most migrations break. Key transformations:

1. **Flatten message streams into ticket replies.** Each Freshchat message becomes a Groove reply. Concatenate all `message_parts [].text.content` values into a single reply body. Drop or annotate rich parts (carousels, quick replies) as descriptive text.

2. **Group rapid sequential messages.** Messages from the same sender within a configurable time window (recommended: 120 seconds) should be combined into a single reply. In testing, this typically reduces reply count per ticket by 30–50% for chat-heavy conversations.

3. **Resolve actor types.** Map `actor_type: "user"` → customer reply, `actor_type: "agent"` → agent reply, `actor_type: "bot"` → note or drop, `message_type: "private"` → internal note.

4. **Download and stage attachments.** Freshchat file URLs are pre-signed S3 links that expire (typically within hours to days depending on the signing configuration). Download all attachments during extraction and store locally or in cloud storage before loading into Groove. Verify that file MIME types are supported by Groove — standard image formats (JPEG, PNG, GIF), PDFs, and common document types are accepted, but test any unusual file types against Groove's upload endpoint.

5. **Build an agent lookup table.** Freshchat uses UUIDs for agent IDs. Groove uses its own integer ID system. Build a mapping table keyed by agent email address. Extract all agents from both systems first and match by email.

6. **Map conversation properties to tags or custom fields.** Freshchat custom properties use `cf_` prefix naming. Map these to Groove tags or custom fields based on your Groove configuration. Note that `cf_` properties are only available for Freshchat accounts within the Freshsales Suite; standalone Freshchat accounts use the standard `properties` array on user objects.

7. **Handle the `restore_id` deduplication problem.** Freshchat's `restore_id` field is a client-side identifier used to associate anonymous sessions with authenticated users. When the same person starts multiple chat sessions before authenticating, Freshchat may create multiple user records with different `restore_id` values but the same email. Your deduplication logic must group by email, not by Freshchat user ID, and merge conversation histories from all matching user records into a single Groove customer.

### Load Phase: Groove API

**REST v1:** Base URL `https://api.groovehq.com/v1`. Authentication via access token (Bearer header). JSON request/response bodies.

**GraphQL v2:** Offers more flexibility for contact and company operations. However, Inbox and Knowledge Base APIs are still being built — use REST v1 for ticket creation and message operations.

**Key REST v1 endpoints:**
- `POST /v1/tickets` — create a ticket
- `POST /v1/tickets/{id}/messages` — add a reply to a ticket
- `GET /v1/customers` — list customers
- `POST /v1/customers` — create a customer

**Customer deduplication behavior:** When you `POST /v1/customers` with an email that already exists in Groove, test whether the API returns the existing record or creates a duplicate. Groove's documentation does not explicitly state the behavior. In practice, implement a check-then-create pattern: `GET /v1/customers?email={email}` before creating, and use the existing record if found. This is critical for idempotent re-runs.

**Attachment constraints:** Groove limits attachments to 25 per message and 20 MB per file.

**Rate limits:** Groove's public documentation does not publish numeric rate limits. In empirical testing, sustained rates of approximately 5–10 requests per second are generally stable, but this varies by account and endpoint. Implement adaptive backoff: start with 200ms delays between requests, and on receiving any 429 response, back off exponentially starting at 2 seconds. Log all 429 responses with timestamps to establish your account's actual limit.

> [!WARNING]
> **Groove API caveat:** Groove's REST v1 API is no longer in active development — no new features or endpoints will be added. The GraphQL API (v2) is the recommended path forward, but its Inbox endpoints are still being built. Check the [Groove developer documentation](https://www.groovehq.com/docs) for current endpoint availability before starting development. Plan for the possibility that you may need to mix REST v1 (for tickets and messages) with GraphQL v2 (for contacts and companies).

## Step-by-Step Migration Process

### Step 1: Extract Reference Data

Pull agents, groups, and channels first. These are small datasets and serve as lookup tables for all subsequent operations.

```python
import requests
import time
from datetime import datetime

FRESHCHAT_URL = "https://yourco.freshchat.com/v2"
HEADERS = {
    "Authorization": "Bearer YOUR_FRESHCHAT_API_KEY",
    "Accept": "application/json"
}

def fetch_all_pages(endpoint, key):
    results = []
    page = 1
    while True:
        resp = requests.get(
            f"{FRESHCHAT_URL}/{endpoint}",
            headers=HEADERS,
            params={"page": page, "items_per_page": 100}
        )
        if resp.status_code == 429:
            retry_after = int(resp.headers.get("Retry-After", 60))
            print(f"Rate limited. Waiting {retry_after}s...")
            time.sleep(retry_after)
            continue
        resp.raise_for_status()
        data = resp.json()
        results.extend(data.get(key, []))
        if not data.get("links", {}).get("next_page"):
            break
        page += 1
    return results

agents = fetch_all_pages("agents", "agents")
groups = fetch_all_pages("groups", "groups")
channels = fetch_all_pages("channels", "channels")

# Build agent lookup: freshchat_id -> {email, name}
agent_lookup = {
    a["id"]: {"email": a.get("email"), "name": f"{a.get('first_name','')} {a.get('last_name','')}".strip()}
    for a in agents
}
```

### Step 2: Extract Users and Conversations

```python
# Extract users by date range
users = fetch_all_pages(
    "users?created_from=2020-01-01T00:00:00.000Z", "users"
)

# Deduplicate users by email (handles restore_id duplicates)
seen_emails = {}
for user in users:
    email = (user.get("email") or "").lower().strip()
    if email and email in seen_emails:
        # Merge conversation lists from duplicate user records
        seen_emails[email]["duplicate_ids"].append(user["id"])
    elif email:
        seen_emails[email] = {**user, "duplicate_ids": [user["id"]]}
    else:
        # Anonymous user — no email
        seen_emails[user["id"]] = {**user, "duplicate_ids": [user["id"]]}

# For each user (and duplicates), get conversation IDs
for key, user_data in seen_emails.items():
    all_conv_ids = []
    for uid in user_data["duplicate_ids"]:
        resp = requests.get(
            f"{FRESHCHAT_URL}/users/{uid}/conversations",
            headers=HEADERS
        )
        if resp.status_code == 429:
            time.sleep(int(resp.headers.get("Retry-After", 60)))
            continue
        conv_ids = [c["id"] for c in resp.json().get("conversations", [])]
        all_conv_ids.extend(conv_ids)
    user_data["conversation_ids"] = list(set(all_conv_ids))
```

### Step 3: Extract Messages and Attachments per Conversation

```python
import os

def fetch_messages(conversation_id):
    messages = []
    page = 1
    while True:
        resp = requests.get(
            f"{FRESHCHAT_URL}/conversations/{conversation_id}/messages",
            headers=HEADERS,
            params={"page": page, "items_per_page": 50}
        )
        if resp.status_code == 429:
            time.sleep(int(resp.headers.get("Retry-After", 60)))
            continue
        resp.raise_for_status()
        data = resp.json()
        messages.extend(data.get("messages", []))
        if not data.get("link"):
            break
        page += 1
    return sorted(messages, key=lambda m: m.get("created_time", ""))

def download_attachment(url, conversation_id, filename):
    """Download attachment from pre-signed S3 URL before it expires."""
    os.makedirs(f"attachments/{conversation_id}", exist_ok=True)
    filepath = f"attachments/{conversation_id}/{filename}"
    if os.path.exists(filepath):
        return filepath  # Idempotent: skip if already downloaded
    resp = requests.get(url, stream=True)
    resp.raise_for_status()
    with open(filepath, "wb") as f:
        for chunk in resp.iter_content(8192):
            f.write(chunk)
    return filepath
```

Download all file attachments from pre-signed S3 URLs immediately during extraction. These URLs expire — if you extract conversation data but delay loading by more than a few hours, the URLs will return 403 errors.

### Step 4: Transform and Group Messages

```python
from datetime import datetime, timezone

def parse_time(time_str):
    """Parse Freshchat UTC timestamp to datetime."""
    # Freshchat uses ISO 8601 format: 2024-01-15T10:30:00.000Z
    return datetime.fromisoformat(time_str.replace("Z", "+00:00"))

def flatten_message_parts(message):
    """Extract text from Freshchat message_parts."""
    parts = []
    for part in message.get("message_parts", []):
        if "text" in part:
            parts.append(part["text"]["content"])
        elif "file" in part:
            parts.append(f"[Attachment: {part['file'].get('name', 'file')}]")
        elif "image" in part:
            parts.append(f"[Image: {part['image'].get('url', '')}]")
        elif "quick_reply_options" in part:
            options = [o.get("label", "") for o in part.get("quick_reply_options", [])]
            parts.append(f"[Bot options: {', '.join(options)}]")
        elif "carousel" in part:
            cards = part["carousel"].get("cards", [])
            card_titles = [c.get("title", "Untitled") for c in cards]
            parts.append(f"[Bot carousel: {', '.join(card_titles)}]")
    return "\n".join(parts) if parts else "[No text content]"

def group_messages(messages, window_seconds=120):
    """Group rapid sequential messages from the same sender."""
    grouped = []
    buffer = None
    for msg in messages:
        if buffer and msg["actor_type"] == buffer["actor_type"]:
            time_diff = parse_time(msg["created_time"]) - parse_time(buffer["created_time"])
            if time_diff.total_seconds() <= window_seconds:
                buffer["body"] += "\n" + flatten_message_parts(msg)
                # Collect any attachments from grouped messages
                buffer["attachments"].extend(msg.get("_downloaded_attachments", []))
                continue
        if buffer:
            grouped.append(buffer)
        buffer = {
            "actor_type": msg["actor_type"],
            "actor_id": msg.get("actor_id"),
            "created_time": msg["created_time"],
            "body": flatten_message_parts(msg),
            "message_type": msg.get("message_type", "normal"),
            "attachments": list(msg.get("_downloaded_attachments", []))
        }
    if buffer:
        grouped.append(buffer)
    return grouped
```

Apply status mapping, resolve agent IDs to Groove agent IDs via your lookup table, and convert grouped message bodies to HTML (wrap in `<p>` tags, convert newlines to `<br>`).

### Step 5: Load into Groove

Create customers first, then tickets, then replay messages in chronological order.

```python
GROOVE_URL = "https://api.groovehq.com/v1"
GROOVE_HEADERS = {
    "Authorization": "Bearer YOUR_GROOVE_ACCESS_TOKEN",
    "Content-Type": "application/json"
}

STATUS_MAP = {
    "new": "opened",
    "assigned": "opened",
    "resolved": "closed",
    "reopened": "opened"
}

def find_or_create_groove_customer(email, name):
    """Check-then-create pattern to avoid duplicates."""
    resp = requests.get(
        f"{GROOVE_URL}/customers",
        headers=GROOVE_HEADERS,
        params={"email": email}
    )
    customers = resp.json().get("customers", [])
    if customers:
        return customers[0]

    resp = requests.post(
        f"{GROOVE_URL}/customers",
        headers=GROOVE_HEADERS,
        json={"email": email, "name": name}
    )
    return resp.json()

def create_groove_ticket(conversation, grouped_messages, agent_map, user_email):
    first_msg = grouped_messages[0] if grouped_messages else None
    body = first_msg["body"] if first_msg else ""

    # Generate subject from first message content
    subject_text = body.replace("\n", " ")[:80]
    if len(body) > 80:
        subject_text += "..."

    ticket_payload = {
        "body": body,
        "from": user_email,
        "to": "support@yourco.com",
        "subject": subject_text,
        "assignee": agent_map.get(conversation.get("assigned_agent_id")),
        "state": STATUS_MAP.get(conversation.get("status"), "opened"),
        "tags": [f"freshchat-import", f"channel:{conversation.get('channel_name', 'unknown')}"]
    }

    resp = requests.post(
        f"{GROOVE_URL}/tickets",
        headers=GROOVE_HEADERS,
        json=ticket_payload
    )
    if resp.status_code == 429:
        time.sleep(2)
        return create_groove_ticket(conversation, grouped_messages, agent_map, user_email)

    ticket = resp.json()
    ticket_number = ticket.get("ticket", {}).get("number") or ticket.get("number")

    # Add subsequent messages as replies in chronological order
    for msg in grouped_messages[1:]:
        msg_payload = {
            "body": msg["body"],
            "note": msg["message_type"] == "private"
        }
        requests.post(
            f"{GROOVE_URL}/tickets/{ticket_number}/messages",
            headers=GROOVE_HEADERS,
            json=msg_payload
        )
        time.sleep(0.2)  # Throttle to stay under rate limits

    return ticket
```

**Timestamp preservation:** When creating tickets with historical data, Groove's REST v1 `POST /v1/tickets` does not document a timestamp override parameter in its public API reference. Test whether passing `created_at` or `sent_at` in the request body is honored. If not, tickets will be stamped with the migration date. As a workaround, prepend the original timestamp to the ticket body: `[Originally sent: 2024-01-15T10:30:00Z]`.

### Step 6: Upload Attachments

Upload temporarily stored files to Groove and associate them with the respective messages. If any conversation exceeds Groove's 25-attachments-per-message limit, split across multiple replies. Track upload success/failure per file in your migration log.

### Step 7: Validate

Run validation checks before going live (see Validation section below).

## Edge Cases and Challenges

### Anonymous Users
Freshchat allows chats without email addresses. Groove requires an email to create a customer. Generate placeholder emails (e.g., `anon-{freshchat_user_id}@yourdomain.com`) and tag these customers with `anonymous-freshchat-user` to make them searchable later. Append the Freshchat user ID as a custom field to maintain traceability.

### Duplicate Users
Freshchat can have multiple user records for the same person — this occurs when different `restore_id` values are generated across sessions before the user authenticates. The same email may appear across 2–5 user records. Deduplicate by lowercase email before creating Groove customers, and merge all conversation histories from duplicate records into the single Groove customer.

### Expired Attachment URLs
Freshchat serves file attachments via pre-signed S3 URLs that expire. Download all attachments during extraction and store them locally or in cloud storage (S3, GCS). If you extract conversation data but delay loading by more than a few hours, the URLs will return 403 Forbidden errors and the files will be unrecoverable without re-extracting from the API.

### Inline Images
Freshchat messages often contain inline base64 images or S3 links within HTML content. Your script must detect `<img>` tags in message body HTML, download the source file (handling both `data:` URIs and HTTPS URLs), upload it to Groove as an attachment, and rewrite the `src` attribute in the HTML payload to reference the Groove-hosted URL.

### Multi-Interaction Conversations
A single Freshchat conversation can be resolved and reopened multiple times, generating new `interaction_id` values each time. All interactions belong to the same `conversation_id`. In Groove, this maps to a single ticket — preserve the full message history chronologically. Optionally, insert a separator note (e.g., `[Conversation reopened: 2024-03-15T14:00:00Z]`) between interaction boundaries for readability.

### Private Notes vs. Bot Private Notes
Freshchat distinguishes between agent private notes (`message_type: "private"`) and bot private notes (`botsPrivateNote: true`). Both should become Groove internal notes, but bot private notes are typically data-collection artifacts (e.g., "User selected language: English") and may not be worth migrating. If you include them, prefix with `[Bot Note]` to differentiate from agent notes.

### Bot Conversations
Decide upfront whether to migrate interactions handled entirely by Freshchat bots. In many accounts, 30–60% of conversations are bot-only. Filtering these out can halve your API call volume and significantly reduce clutter in Groove.

### Deactivated Agents
Conversations assigned to now-deactivated Freshchat agents need a fallback owner in Groove. Options: map to a default "Migration Import" agent account, or set to unassigned. Document the mapping in your migration log so agents can investigate if needed.

### WhatsApp Outbound Messages
Freshchat's outbound WhatsApp message templates (`/v2/outbound-messages/whatsapp`) have no Groove equivalent. These cannot be migrated. If WhatsApp conversation history is business-critical, archive it separately (e.g., export via Reports API and store as CSV).

### Freshchat Contacts API
Freshchat accounts within the Freshworks Suite (Freshsales + Freshchat) may expose customer data through `/v2/contacts` rather than `/v2/users`. If your account uses the Freshsales Suite, check both endpoints — `contacts` may contain richer profile data including custom properties, lifecycle stage, and deal associations that are not present on the `users` endpoint.

## Limitations and Constraints

### Groove-Side Limitations
- **No true custom objects.** Groove's data model is flat: tickets, customers, agents. If you rely on Freshchat's conversation properties heavily, you'll need to map them to tags or limited custom fields.
- **GraphQL v2 Inbox APIs are incomplete.** Groove recommends REST v1 for Inbox operations, but REST v1 is no longer in active development. No new features or endpoints will be added.
- **Simplified reporting.** Groove's reporting is more limited than Freshchat's granular metrics API. Historical volume trends, first-response-time distributions, and agent performance breakdowns may not be reproducible.
- **Rate limits not published.** Public Groove docs do not publish numeric rate limits. Empirically, 5–10 requests/second appears stable, but implement adaptive backoff from the start.
- **Attachment limits.** 25 attachments per message, 20 MB per file.
- **No programmatic CSAT import.** Groove's current API does not expose an endpoint for importing historical satisfaction ratings. Store them as tags or custom fields.

### Freshchat-Side Limitations
- **No bulk conversation list endpoint.** You must discover conversations via user-level queries or the Reports API.
- **Rate limits are plan-dependent.** The Reports API documents 100 calls per minute. Other endpoint limits vary by plan and are not fully documented. Monitor `X-RateLimit-Remaining` headers.
- **Reports API time windows.** Chat-Transcript reports are limited to 24-hour extraction windows. For a 1-year backfill, this means a minimum of 365 sequential API calls just for conversation discovery.
- **Messages pagination caps at 50 per page.** For conversations with hundreds of messages, expect many paginated calls per conversation.

### Data That Cannot Be Migrated
- Bot flows and automation rules (must be rebuilt manually in Groove)
- IntelliAssign / Omniroute configuration
- WhatsApp message templates
- CSAT survey configuration (only raw ratings can move, as tags/custom fields)
- Agent availability/status history
- Dashboard metric history and historical reporting data

## Validation and Testing

Never migrate directly to production. **Always run a test migration on a separate Groove account first.** Groove does not offer a sandbox environment, so create a dedicated test account for trial runs.

### Record Count Comparison

| Object | Freshchat Count (Source) | Groove Count (Target) | Delta | Acceptable? |
|---|---|---|---|---|
| Users / Customers | | | | 0% delta |
| Agents | | | | Manual — verify all mapped |
| Conversations / Tickets | | | | 0% for in-scope conversations |
| Total Messages / Replies | | | | <5% acceptable (grouped messages reduce count) |
| Attachments | | | | 0% delta |

A delta in messages/replies is expected due to message grouping. Any delta in customers, tickets, or attachments means records were dropped. Investigate before going live.

### Sampling Strategy

Pull 50 records — intentionally pick edge cases:
- Oldest conversation in the system
- Conversation with the most messages (test pagination completeness)
- Conversation with attachments (verify files are accessible)
- Conversation that was resolved and reopened multiple times (verify chronological integrity)
- Conversation with bot interactions (verify flattening logic)
- Conversation assigned to a now-deactivated agent (verify fallback assignment)
- Conversation from an anonymous user (verify placeholder email)

For each, verify: correct customer association, correct agent assignment, message ordering matches source, all attachments are downloadable, status is correctly mapped, and any custom field values are present.

### Agent UAT

Have actual support agents (not just admins or engineers) run scripted checks:
1. Find a specific migrated ticket by customer email
2. Verify message chronology matches the original Freshchat conversation
3. Open each attachment and confirm it renders correctly
4. Confirm the customer profile shows correct contact information
5. Reply to the migrated ticket and verify the thread is not broken

### Rollback Planning

Groove does not support bulk delete via API. If the migration produces unacceptable results:
1. **Best option:** Use a separate test Groove account for all trial runs (strongly recommended — eliminates rollback risk entirely)
2. **Contact Groove support** for a data reset on your production account. Response time varies; plan for 1–3 business days.
3. **API deletion:** Delete tickets one by one via `DELETE /v1/tickets/{number}`. At ~5 requests/second, deleting 10,000 tickets takes approximately 33 minutes.

## Post-Migration Tasks

Once the final delta sync completes and your support channels are pointed to Groove:

- **Rebuild automations.** Freshchat assignment rules, auto-resolve rules, and bot flows do not migrate. Recreate equivalent rules in Groove's automation builder. Common mappings: IntelliAssign → Groove round-robin assignment; auto-resolve after X hours → Groove auto-close rules.
- **Configure Groove integrations.** Connect Slack, Shopify, Stripe, Salesforce, or other tools your team relies on. Verify webhooks are pointing to Groove, not Freshchat.
- **Update customer-facing widgets.** Swap the Freshchat widget JavaScript snippet for Groove's support widget on your website. Update any mobile SDK references.
- **Train your team.** Groove's interface differs from Freshchat. Key differences agents should understand: folders replace channels/topics, tags replace conversation properties, and internal notes replace private messages.
- **Monitor for data inconsistencies.** Watch for customer complaints about missing history or misattributed conversations in the first two weeks. Set up a `#migration-issues` Slack channel for agents to report problems.
- **Keep Freshchat in read-only mode.** Do not decommission Freshchat until you've confirmed all data is validated in Groove. Keep it as a read-only archive for 30–90 days as a safety net. Freshchat does not have a native "read-only" mode; instead, disable all channels and remove the widget from your site.

## Best Practices

1. **Decouple the pipeline.** Never stream directly from Freshchat to Groove. Extract to a staging database first. This protects you against network failures, rate limits, and expiring S3 URLs, and enables re-runs without re-querying the source.
2. **Log everything.** Persist every source ID, target ID, HTTP status code, attempt count, and last error. Maintain a strict mapping table of `freshchat_conversation_id → groove_ticket_number`. This is essential for delta syncs, troubleshooting, and post-migration audits.
3. **Run at least two test migrations** before the production run. The first will expose mapping bugs; the second validates your fixes.
4. **Validate incrementally** — check the first 100 tickets as they load, don't wait until all 10,000 are complete to start checking.
5. **Throttle API calls conservatively.** Stay well under rate limits, especially on Freshchat's side. A 429 lockout mid-migration wastes hours and complicates checkpointing.
6. **Map agents before anything else.** Agent resolution errors cascade into every ticket — a missing agent mapping means every ticket assigned to that agent will fail or be unassigned.
7. **Handle attachments in a separate pass.** Download all files during extraction, store locally with a naming convention that maps back to conversation/message IDs, then upload during loading. Never stream directly from Freshchat S3 URLs to Groove — the URLs will expire mid-migration.
8. **Build idempotent scripts.** Use your `freshchat_conversation_id → groove_ticket_number` mapping table to skip already-migrated records on re-run. If reruns create duplicates, the script is not finished. Checkpoint every page and every ticket so reruns resume cleanly.
9. **Run a delta sync.** Perform the bulk migration over several days. On the day of cutover, run a delta sync using Freshchat's `updated_from` filter to catch only conversations created or updated during the migration window.
10. **Tag all migrated tickets.** Apply a consistent tag (e.g., `freshchat-import`) to every migrated ticket. This makes it easy to identify, filter, and if necessary bulk-delete migrated records.

## Sample Data Mapping Table

| Freshchat Field | Groove Field | Transformation |
|---|---|---|
| `user.id` | `customer` custom field (`legacy_freshchat_user_id`) | Direct copy for traceability |
| `user.email` | `customer.email` | Lowercase + dedupe across `restore_id` duplicates |
| `first_name` + `last_name` | `customer.name` | Concatenate with space separator |
| `conversation.id` | Ticket tag or custom field (`legacy_freshchat_conversation_id`) | Direct copy for traceability |
| `conversation.status` | `ticket.state` | Mapped per status matrix above |
| `conversation.assigned_agent_id` | `ticket.assignee` | Agent lookup by email; fallback to unassigned |
| `channel_id` / channel name | Mailbox or tag | Routing choice per your Groove structure |
| `message.actor_type` | Reply vs. note | user → customer reply, agent → agent reply, system/bot → note or drop |
| `message.message_parts [].text.content` | `message.body` | Concatenate parts, group by 120-second time window |
| `message.created_time` | `message.sent_at` or body prefix | Preserve chronology; test API timestamp acceptance |
| Attachments | Attachments on message | Download during extraction, re-upload during load (split if >25/message) |
| `conversation.properties.cf_*` | Tags or custom fields | Map per property; only available in Freshsales Suite accounts |
| CSAT rating | Tag (e.g., `csat:4`) | No native CSAT import in Groove API |

## Frequently asked questions

### Does Groove have a native Freshchat importer?

No. Groove's documented migration path points customers to Import2 or the API, and Freshchat is not listed in Groove's documented Import2 source list. You must extract data via Freshchat's REST API v2, transform conversations into Groove's ticket format, and load through Groove's REST v1 or GraphQL v2 API — using custom scripts, third-party services, or a managed migration provider.

### How long does a Freshchat to Groove migration take?

It depends on data volume and API rate limits. A small account with under 5,000 conversations can be migrated in 2–5 days including testing. Larger accounts with 20,000+ conversations, heavy attachments, and complex bot interactions may take 1–3 weeks due to rate limits and the per-conversation message extraction required by Freshchat's API.

### How do you handle chat messages migrating into an email ticketing system?

Build a transformation layer that groups rapid, sequential chat messages from the same sender within a time window (e.g., 2 minutes) into a single consolidated HTML block before pushing to Groove. Without grouping, a 30-second chat exchange becomes five separate ticket replies, cluttering the Groove UI.

### What data can't be migrated from Freshchat to Groove?

Bot flows, automation rules, IntelliAssign configurations, WhatsApp outbound message templates, CSAT survey settings (raw ratings can move, configuration cannot), and agent availability history cannot be migrated. Rich bot message parts like carousels and quick-reply buttons must be flattened to text or dropped.

### What happens to Freshchat attachments during the migration?

Attachments must be downloaded during extraction because Freshchat serves files via pre-signed S3 URLs that expire. Store them locally or in cloud storage, then upload to Groove's API. Groove limits attachments to 25 per message and 20 MB per file, so large threads may need attachments split across multiple replies.
